mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-06-04 05:38:12 +02:00
fix: stricter metadata matching, respect embedLyrics setting, improve Apple Music lyrics
- Re-enrich: reject candidates that don't match title/artist/album unless exact ISRC match - Respect settings.embedLyrics instead of hardcoding true in re-enrich flows - Skip lyrics resolution in NativeDownloadFinalizer when not needed - Apple Music lyrics: use direct catalog API with token scraping instead of Paxsenix search - Support ELRC/ELRCMultiPerson/Plain formats in Apple Music lyrics response - Add confidence check in metadata auto-fill to prevent applying wrong metadata - Add tests for stricter re-enrich matching logic
This commit is contained in:
@@ -1081,10 +1081,11 @@ object NativeDownloadFinalizer {
|
||||
val genre = resultString(input, "genre").ifBlank { requestString(input, "genre") }
|
||||
val label = resultString(input, "label").ifBlank { requestString(input, "label") }
|
||||
val copyright = resultString(input, "copyright").ifBlank { requestString(input, "copyright") }
|
||||
val lyrics = resolveLyricsLrc(input)
|
||||
val shouldEmbedLyrics = input.request.optBoolean("embed_lyrics", false) &&
|
||||
(input.request.optString("lyrics_mode", "embed") == "embed" ||
|
||||
input.request.optString("lyrics_mode", "embed") == "both") &&
|
||||
val lyricsMode = input.request.optString("lyrics_mode", "embed")
|
||||
val shouldResolveLyrics = input.request.optBoolean("embed_lyrics", false) &&
|
||||
(lyricsMode == "embed" || lyricsMode == "both")
|
||||
val lyrics = if (shouldResolveLyrics) resolveLyricsLrc(input) else ""
|
||||
val shouldEmbedLyrics = shouldResolveLyrics &&
|
||||
lyrics.isNotBlank() &&
|
||||
lyrics != "[instrumental:true]"
|
||||
if (format == "flac") {
|
||||
|
||||
+26
-5
@@ -600,6 +600,10 @@ func selectBestReEnrichTrack(req reEnrichRequest, tracks []ExtTrackMetadata) *Ex
|
||||
for i := range tracks {
|
||||
track := &tracks[i]
|
||||
score := 0
|
||||
exactISRCMatch := currentISRC != "" && strings.EqualFold(currentISRC, strings.TrimSpace(track.ISRC))
|
||||
titleMatches := req.TrackName != "" && track.Name != "" && titlesMatch(req.TrackName, track.Name)
|
||||
artistMatches := req.ArtistName != "" && track.Artists != "" && artistsMatch(req.ArtistName, track.Artists)
|
||||
albumMatches := currentAlbum != "" && track.AlbumName != "" && titlesMatch(currentAlbum, track.AlbumName)
|
||||
|
||||
resolved := resolvedTrackInfo{
|
||||
Title: track.Name,
|
||||
@@ -607,22 +611,39 @@ func selectBestReEnrichTrack(req reEnrichRequest, tracks []ExtTrackMetadata) *Ex
|
||||
ISRC: track.ISRC,
|
||||
Duration: track.DurationMS / 1000,
|
||||
}
|
||||
if trackMatchesRequest(downloadReq, resolved, "ReEnrich") {
|
||||
verified := trackMatchesRequest(downloadReq, resolved, "ReEnrich")
|
||||
|
||||
if !exactISRCMatch {
|
||||
if req.TrackName != "" && !titleMatches {
|
||||
continue
|
||||
}
|
||||
if req.ArtistName != "" && !artistMatches {
|
||||
continue
|
||||
}
|
||||
if req.TrackName == "" && req.ArtistName == "" && currentAlbum != "" && !albumMatches {
|
||||
continue
|
||||
}
|
||||
if req.TrackName == "" && req.ArtistName == "" && currentAlbum == "" && !verified {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if verified {
|
||||
score += 2000
|
||||
}
|
||||
|
||||
if currentISRC != "" && strings.EqualFold(currentISRC, strings.TrimSpace(track.ISRC)) {
|
||||
if exactISRCMatch {
|
||||
score += 10000
|
||||
}
|
||||
if req.TrackName != "" && track.Name != "" && titlesMatch(req.TrackName, track.Name) {
|
||||
if titleMatches {
|
||||
score += 400
|
||||
}
|
||||
if req.ArtistName != "" && track.Artists != "" && artistsMatch(req.ArtistName, track.Artists) {
|
||||
if artistMatches {
|
||||
score += 320
|
||||
}
|
||||
if currentAlbum != "" && track.AlbumName != "" {
|
||||
switch {
|
||||
case titlesMatch(currentAlbum, track.AlbumName):
|
||||
case albumMatches:
|
||||
score += 120
|
||||
case strings.Contains(strings.ToLower(track.AlbumName), strings.ToLower(currentAlbum)),
|
||||
strings.Contains(strings.ToLower(currentAlbum), strings.ToLower(track.AlbumName)):
|
||||
|
||||
@@ -407,6 +407,62 @@ func TestSelectBestReEnrichTrackPrefersCandidateWithReleaseDate(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectBestReEnrichTrackRejectsMismatchedSearchResults(t *testing.T) {
|
||||
req := reEnrichRequest{
|
||||
TrackName: "Song Title",
|
||||
ArtistName: "Artist Name",
|
||||
AlbumName: "Album Name",
|
||||
DurationMs: 180000,
|
||||
}
|
||||
|
||||
tracks := []ExtTrackMetadata{
|
||||
{
|
||||
ID: "wrong-rich-metadata",
|
||||
Name: "Different Song",
|
||||
Artists: "Different Artist",
|
||||
AlbumName: "Album Name",
|
||||
DurationMS: 180000,
|
||||
ReleaseDate: "2024-03-09",
|
||||
TrackNumber: 4,
|
||||
DiscNumber: 1,
|
||||
ISRC: "WRONG1234567",
|
||||
ProviderID: "deezer",
|
||||
},
|
||||
}
|
||||
|
||||
if best := selectBestReEnrichTrack(req, tracks); best != nil {
|
||||
t.Fatalf("selected track = %q, want no match", best.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectBestReEnrichTrackAllowsExactISRCDespiteMetadataMismatch(t *testing.T) {
|
||||
req := reEnrichRequest{
|
||||
TrackName: "Song Title",
|
||||
ArtistName: "Artist Name",
|
||||
ISRC: "USRC17607839",
|
||||
DurationMs: 999999000,
|
||||
}
|
||||
|
||||
tracks := []ExtTrackMetadata{
|
||||
{
|
||||
ID: "same-isrc",
|
||||
Name: "Different Song",
|
||||
Artists: "Different Artist",
|
||||
DurationMS: 180000,
|
||||
ISRC: "USRC17607839",
|
||||
ProviderID: "deezer",
|
||||
},
|
||||
}
|
||||
|
||||
best := selectBestReEnrichTrack(req, tracks)
|
||||
if best == nil {
|
||||
t.Fatal("expected exact ISRC candidate to be selected")
|
||||
}
|
||||
if best.ID != "same-isrc" {
|
||||
t.Fatalf("selected track = %q, want exact ISRC candidate", best.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildReEnrichFFmpegMetadataOmitsEmptyFields(t *testing.T) {
|
||||
req := reEnrichRequest{
|
||||
TrackName: "Song",
|
||||
|
||||
+213
-23
@@ -7,7 +7,9 @@ import (
|
||||
"math"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -15,6 +17,8 @@ type AppleMusicClient struct {
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
const appleMusicCatalogBaseURL = "https://amp-api.music.apple.com/v1/catalog/us"
|
||||
|
||||
type appleMusicSearchResult struct {
|
||||
ID string `json:"id"`
|
||||
SongName string `json:"songName"`
|
||||
@@ -23,9 +27,33 @@ type appleMusicSearchResult struct {
|
||||
Duration int `json:"duration"`
|
||||
}
|
||||
|
||||
type appleMusicCatalogSearchResponse struct {
|
||||
Results struct {
|
||||
Songs *struct {
|
||||
Data []struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"data"`
|
||||
} `json:"songs"`
|
||||
} `json:"results"`
|
||||
Resources *struct {
|
||||
Songs map[string]struct {
|
||||
Attributes struct {
|
||||
Name string `json:"name"`
|
||||
ArtistName string `json:"artistName"`
|
||||
AlbumName string `json:"albumName"`
|
||||
DurationInMillis int `json:"durationInMillis"`
|
||||
} `json:"attributes"`
|
||||
} `json:"songs"`
|
||||
} `json:"resources"`
|
||||
}
|
||||
|
||||
type paxResponse struct {
|
||||
Type string `json:"type"` // "Syllable" or "Line"
|
||||
Content []paxLyrics `json:"content"` // List of lyric lines
|
||||
Type string `json:"type"` // "Syllable" or "Line"
|
||||
Content []paxLyrics `json:"content"`
|
||||
ELRC string `json:"elrc"`
|
||||
ELRCMultiPerson string `json:"elrcMultiPerson"`
|
||||
Plain string `json:"plain"`
|
||||
TTMLContent string `json:"ttmlContent"`
|
||||
}
|
||||
|
||||
type paxLyrics struct {
|
||||
@@ -44,6 +72,11 @@ type paxLyricDetail struct {
|
||||
EndTime *int `json:"endtime"`
|
||||
}
|
||||
|
||||
var (
|
||||
appleMusicTokenMu sync.Mutex
|
||||
appleMusicCachedToken string
|
||||
)
|
||||
|
||||
func NewAppleMusicClient() *AppleMusicClient {
|
||||
return &AppleMusicClient{
|
||||
httpClient: NewMetadataHTTPClient(20 * time.Second),
|
||||
@@ -100,36 +133,164 @@ func selectBestAppleMusicSearchResult(results []appleMusicSearchResult, trackNam
|
||||
return &results[bestIndex]
|
||||
}
|
||||
|
||||
func (c *AppleMusicClient) getAppleMusicToken() (string, error) {
|
||||
appleMusicTokenMu.Lock()
|
||||
defer appleMusicTokenMu.Unlock()
|
||||
|
||||
if appleMusicCachedToken != "" {
|
||||
return appleMusicCachedToken, nil
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("GET", "https://beta.music.apple.com", nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create apple music page request: %w", err)
|
||||
}
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36")
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to fetch apple music page: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("apple music page returned HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read apple music page: %w", err)
|
||||
}
|
||||
|
||||
indexPath := regexp.MustCompile(`/assets/index~[^"' <]+\.js`).FindString(string(body))
|
||||
if indexPath == "" {
|
||||
return "", fmt.Errorf("apple music index script not found")
|
||||
}
|
||||
|
||||
jsReq, err := http.NewRequest("GET", "https://beta.music.apple.com"+indexPath, nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create apple music script request: %w", err)
|
||||
}
|
||||
jsReq.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36")
|
||||
|
||||
jsResp, err := c.httpClient.Do(jsReq)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to fetch apple music script: %w", err)
|
||||
}
|
||||
defer jsResp.Body.Close()
|
||||
|
||||
if jsResp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("apple music script returned HTTP %d", jsResp.StatusCode)
|
||||
}
|
||||
|
||||
jsBody, err := io.ReadAll(jsResp.Body)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read apple music script: %w", err)
|
||||
}
|
||||
|
||||
token := regexp.MustCompile(`eyJh[^"' <]+`).FindString(string(jsBody))
|
||||
if token == "" {
|
||||
return "", fmt.Errorf("apple music token not found")
|
||||
}
|
||||
|
||||
appleMusicCachedToken = token
|
||||
return token, nil
|
||||
}
|
||||
|
||||
func clearAppleMusicToken() {
|
||||
appleMusicTokenMu.Lock()
|
||||
defer appleMusicTokenMu.Unlock()
|
||||
appleMusicCachedToken = ""
|
||||
}
|
||||
|
||||
func (c *AppleMusicClient) searchSongWithToken(token, query string) ([]appleMusicSearchResult, error) {
|
||||
params := url.Values{}
|
||||
params.Set("term", query)
|
||||
params.Set("types", "songs")
|
||||
params.Set("limit", "25")
|
||||
params.Set("l", "en-US")
|
||||
params.Set("platform", "web")
|
||||
params.Set("format[resources]", "map")
|
||||
params.Set("include[songs]", "artists")
|
||||
params.Set("extend", "artistUrl")
|
||||
|
||||
searchURL := appleMusicCatalogBaseURL + "/search?" + params.Encode()
|
||||
req, err := http.NewRequest("GET", searchURL, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create apple music catalog request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
req.Header.Set("Origin", "https://music.apple.com")
|
||||
req.Header.Set("Referer", "https://music.apple.com/")
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:95.0) Gecko/20100101 Firefox/95.0")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("Accept-Language", "en-US,en;q=0.5")
|
||||
req.Header.Set("x-apple-renewal", "true")
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("apple music catalog search failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusUnauthorized {
|
||||
return nil, fmt.Errorf("apple music catalog search unauthorized")
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("apple music catalog search returned HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var searchResp appleMusicCatalogSearchResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&searchResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode apple music catalog response: %w", err)
|
||||
}
|
||||
|
||||
if searchResp.Results.Songs == nil || searchResp.Resources == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
results := make([]appleMusicSearchResult, 0, len(searchResp.Results.Songs.Data))
|
||||
for _, item := range searchResp.Results.Songs.Data {
|
||||
detail, ok := searchResp.Resources.Songs[item.ID]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
attr := detail.Attributes
|
||||
results = append(results, appleMusicSearchResult{
|
||||
ID: item.ID,
|
||||
SongName: attr.Name,
|
||||
ArtistName: attr.ArtistName,
|
||||
AlbumName: attr.AlbumName,
|
||||
Duration: attr.DurationInMillis,
|
||||
})
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
||||
func (c *AppleMusicClient) SearchSong(trackName, artistName string, durationSec float64) (string, error) {
|
||||
query := trackName + " " + artistName
|
||||
if strings.TrimSpace(query) == "" {
|
||||
return "", fmt.Errorf("empty search query")
|
||||
}
|
||||
|
||||
encodedQuery := url.QueryEscape(query)
|
||||
searchURL := fmt.Sprintf("https://lyrics.paxsenix.org/apple-music/search?q=%s", encodedQuery)
|
||||
|
||||
req, err := http.NewRequest("GET", searchURL, nil)
|
||||
token, err := c.getAppleMusicToken()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create request: %w", err)
|
||||
return "", err
|
||||
}
|
||||
|
||||
req.Header.Set("User-Agent", appUserAgent())
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
searchResp, err := c.searchSongWithToken(token, strings.TrimSpace(query))
|
||||
if err != nil && strings.Contains(strings.ToLower(err.Error()), "unauthorized") {
|
||||
clearAppleMusicToken()
|
||||
token, tokenErr := c.getAppleMusicToken()
|
||||
if tokenErr != nil {
|
||||
return "", tokenErr
|
||||
}
|
||||
searchResp, err = c.searchSongWithToken(token, strings.TrimSpace(query))
|
||||
}
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("apple music search failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return "", fmt.Errorf("apple music search returned HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var searchResp []appleMusicSearchResult
|
||||
if err := json.NewDecoder(resp.Body).Decode(&searchResp); err != nil {
|
||||
return "", fmt.Errorf("failed to decode apple music response: %w", err)
|
||||
return "", err
|
||||
}
|
||||
|
||||
best := selectBestAppleMusicSearchResult(searchResp, trackName, artistName, durationSec)
|
||||
@@ -174,8 +335,33 @@ func (c *AppleMusicClient) FetchLyricsByID(songID string) (string, error) {
|
||||
}
|
||||
|
||||
func formatPaxLyricsToLRC(rawJSON string, multiPersonWordByWord bool, preserveWordTiming bool) (string, error) {
|
||||
var stringPayload string
|
||||
if err := json.Unmarshal([]byte(rawJSON), &stringPayload); err == nil {
|
||||
stringPayload = strings.TrimSpace(stringPayload)
|
||||
if stringPayload != "" {
|
||||
return stringPayload, nil
|
||||
}
|
||||
}
|
||||
|
||||
var paxResp paxResponse
|
||||
if err := json.Unmarshal([]byte(rawJSON), &paxResp); err == nil && paxResp.Content != nil {
|
||||
if err := json.Unmarshal([]byte(rawJSON), &paxResp); err == nil &&
|
||||
(paxResp.Content != nil ||
|
||||
strings.TrimSpace(paxResp.ELRCMultiPerson) != "" ||
|
||||
strings.TrimSpace(paxResp.ELRC) != "" ||
|
||||
strings.TrimSpace(paxResp.Plain) != "" ||
|
||||
strings.TrimSpace(paxResp.TTMLContent) != "") {
|
||||
if preserveWordTiming && multiPersonWordByWord && strings.TrimSpace(paxResp.ELRCMultiPerson) != "" {
|
||||
return strings.TrimSpace(paxResp.ELRCMultiPerson), nil
|
||||
}
|
||||
if preserveWordTiming && strings.TrimSpace(paxResp.ELRC) != "" {
|
||||
return strings.TrimSpace(paxResp.ELRC), nil
|
||||
}
|
||||
if strings.TrimSpace(paxResp.Plain) != "" && len(paxResp.Content) == 0 {
|
||||
return strings.TrimSpace(paxResp.Plain), nil
|
||||
}
|
||||
if len(paxResp.Content) == 0 {
|
||||
return "", fmt.Errorf("unsupported apple music lyrics payload")
|
||||
}
|
||||
return formatPaxContent(paxResp.Type, paxResp.Content, multiPersonWordByWord, preserveWordTiming), nil
|
||||
}
|
||||
|
||||
@@ -270,6 +456,10 @@ func (c *AppleMusicClient) FetchLyrics(
|
||||
|
||||
lrcText, err := formatPaxLyricsToLRC(rawLyrics, multiPersonWordByWord, preserveWordTiming)
|
||||
if err != nil {
|
||||
trimmedRaw := strings.TrimSpace(rawLyrics)
|
||||
if strings.HasPrefix(trimmedRaw, "{") || strings.HasPrefix(trimmedRaw, "[") {
|
||||
return nil, err
|
||||
}
|
||||
lrcText = rawLyrics
|
||||
}
|
||||
|
||||
|
||||
@@ -131,14 +131,18 @@ func TestLyricsCacheParsingAndLRCLibClient(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestExternalLyricsProvidersWithFakeHTTP(t *testing.T) {
|
||||
clearAppleMusicToken()
|
||||
defer clearAppleMusicToken()
|
||||
|
||||
paxJSON := `{"type":"Syllable","content":[{"timestamp":1000,"oppositeTurn":true,"background":true,"text":[{"text":"Hel","part":true,"timestamp":1000},{"text":"lo","part":false,"timestamp":1200,"endtime":1500}],"backgroundText":[{"text":"bg","part":false,"timestamp":900}]}]}`
|
||||
apple := &AppleMusicClient{httpClient: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
switch {
|
||||
case strings.Contains(req.URL.Path, "/apple-music/search"):
|
||||
if req.URL.Query().Get("q") == "bad" {
|
||||
return &http.Response{StatusCode: 500, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`error`)), Request: req}, nil
|
||||
}
|
||||
return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`[{"id":"apple-2","songName":"Other","artistName":"Other","duration":1000},{"id":"apple-1","songName":"Song","artistName":"Artist","albumName":"Album","duration":180000}]`)), Request: req}, nil
|
||||
case req.URL.Host == "beta.music.apple.com" && (req.URL.Path == "" || req.URL.Path == "/"):
|
||||
return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`<script src="/assets/index~test.js"></script>`)), Request: req}, nil
|
||||
case req.URL.Host == "beta.music.apple.com" && req.URL.Path == "/assets/index~test.js":
|
||||
return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`const token="eyJhbGci.test";`)), Request: req}, nil
|
||||
case req.URL.Host == "amp-api.music.apple.com" && strings.Contains(req.URL.Path, "/v1/catalog/us/search"):
|
||||
return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{"results":{"songs":{"data":[{"id":"apple-2"},{"id":"apple-1"}]}},"resources":{"songs":{"apple-2":{"attributes":{"name":"Other","artistName":"Other","durationInMillis":1000}},"apple-1":{"attributes":{"name":"Song","artistName":"Artist","albumName":"Album","durationInMillis":180000}}}}}`)), Request: req}, nil
|
||||
case strings.Contains(req.URL.Path, "/apple-music/lyrics"):
|
||||
return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(paxJSON)), Request: req}, nil
|
||||
default:
|
||||
@@ -177,6 +181,9 @@ func TestExternalLyricsProvidersWithFakeHTTP(t *testing.T) {
|
||||
if !strings.Contains(elrc, "<00:") {
|
||||
t.Fatalf("elrc pax should include inline word timing: %q", elrc)
|
||||
}
|
||||
if preferred, err := formatPaxLyricsToLRC(`{"elrcMultiPerson":"[00:01.00]v1:<00:01.00>Hello","content":[{"timestamp":1000,"text":[{"text":"Fallback","part":false}]}]}`, true, true); err != nil || !strings.Contains(preferred, "Hello") {
|
||||
t.Fatalf("preferred apple elrc = %q/%v", preferred, err)
|
||||
}
|
||||
if _, err := apple.SearchSong("", "", 0); err == nil {
|
||||
t.Fatal("expected empty apple search error")
|
||||
}
|
||||
|
||||
@@ -883,12 +883,13 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
||||
List<String>? updateFields,
|
||||
}) async {
|
||||
final durationMs = (item.duration ?? 0) * 1000;
|
||||
final artistTagMode = ref.read(settingsProvider).artistTagMode;
|
||||
final settings = ref.read(settingsProvider);
|
||||
final artistTagMode = settings.artistTagMode;
|
||||
final request = <String, dynamic>{
|
||||
'file_path': item.filePath,
|
||||
'cover_url': '',
|
||||
'max_quality': true,
|
||||
'embed_lyrics': true,
|
||||
'embed_lyrics': settings.embedLyrics,
|
||||
'artist_tag_mode': artistTagMode,
|
||||
'spotify_id': '',
|
||||
'track_name': item.trackName,
|
||||
|
||||
@@ -4408,12 +4408,13 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
List<String>? updateFields,
|
||||
}) async {
|
||||
final durationMs = (item.duration ?? 0) * 1000;
|
||||
final artistTagMode = ref.read(settingsProvider).artistTagMode;
|
||||
final settings = ref.read(settingsProvider);
|
||||
final artistTagMode = settings.artistTagMode;
|
||||
final request = <String, dynamic>{
|
||||
'file_path': item.filePath,
|
||||
'cover_url': '',
|
||||
'max_quality': true,
|
||||
'embed_lyrics': true,
|
||||
'embed_lyrics': settings.embedLyrics,
|
||||
'artist_tag_mode': artistTagMode,
|
||||
'spotify_id': '',
|
||||
'track_name': item.trackName,
|
||||
|
||||
@@ -599,6 +599,54 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
|
||||
return score;
|
||||
}
|
||||
|
||||
bool _metadataTextMatches(String current, String candidate) {
|
||||
if (current.isEmpty || candidate.isEmpty) return false;
|
||||
return current == candidate ||
|
||||
candidate.contains(current) ||
|
||||
current.contains(candidate);
|
||||
}
|
||||
|
||||
bool _metadataMatchIsConfident(
|
||||
Map<String, dynamic> track, {
|
||||
required String currentTitle,
|
||||
required String currentArtist,
|
||||
required String currentAlbum,
|
||||
required String currentIsrc,
|
||||
}) {
|
||||
final candidateIsrc = (track['isrc']?.toString() ?? '')
|
||||
.trim()
|
||||
.toUpperCase();
|
||||
if (currentIsrc.isNotEmpty && candidateIsrc == currentIsrc) {
|
||||
return true;
|
||||
}
|
||||
|
||||
final candidateTitle = _normalizeMetadataText(
|
||||
(track['name'] ?? track['title'] ?? '').toString(),
|
||||
);
|
||||
final candidateArtist = _normalizeMetadataText(
|
||||
(track['artists'] ?? track['artist'] ?? '').toString(),
|
||||
);
|
||||
final candidateAlbum = _normalizeMetadataText(
|
||||
(track['album_name'] ?? track['album'] ?? '').toString(),
|
||||
);
|
||||
|
||||
final titleMatches = _metadataTextMatches(currentTitle, candidateTitle);
|
||||
final artistMatches = _metadataTextMatches(currentArtist, candidateArtist);
|
||||
final albumMatches = _metadataTextMatches(currentAlbum, candidateAlbum);
|
||||
|
||||
if (currentTitle.isNotEmpty && currentArtist.isNotEmpty) {
|
||||
return titleMatches && artistMatches;
|
||||
}
|
||||
if (currentTitle.isNotEmpty && currentAlbum.isNotEmpty) {
|
||||
return titleMatches && albumMatches;
|
||||
}
|
||||
if (currentTitle.isNotEmpty) {
|
||||
return titleMatches;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
Future<void> _fetchAndFill() async {
|
||||
if (_autoFillFields.isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
@@ -683,6 +731,24 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
|
||||
best = result;
|
||||
}
|
||||
}
|
||||
|
||||
if (best != null &&
|
||||
!_metadataMatchIsConfident(
|
||||
best,
|
||||
currentTitle: normalizedTitle,
|
||||
currentArtist: normalizedArtist,
|
||||
currentAlbum: normalizedAlbum,
|
||||
currentIsrc: currentIsrc,
|
||||
)) {
|
||||
best = null;
|
||||
}
|
||||
|
||||
if (best == null) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(context.l10n.editMetadataAutoFillNoResults)),
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
final selectedBest = best;
|
||||
|
||||
@@ -2921,7 +2921,8 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
if (!_fileExists) return;
|
||||
|
||||
try {
|
||||
final artistTagMode = ref.read(settingsProvider).artistTagMode;
|
||||
final settings = ref.read(settingsProvider);
|
||||
final artistTagMode = settings.artistTagMode;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(context.l10n.trackReEnrichSearching)),
|
||||
);
|
||||
@@ -2931,7 +2932,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
'file_path': cleanFilePath,
|
||||
'cover_url': _coverUrl ?? '',
|
||||
'max_quality': true,
|
||||
'embed_lyrics': true,
|
||||
'embed_lyrics': settings.embedLyrics,
|
||||
'artist_tag_mode': artistTagMode,
|
||||
'spotify_id': _spotifyId ?? '',
|
||||
'track_name': trackName,
|
||||
|
||||
Reference in New Issue
Block a user