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:
zarzet
2026-05-20 23:16:51 +07:00
parent 0952b76e11
commit 822c094c8c
9 changed files with 387 additions and 43 deletions
@@ -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
View File
@@ -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)):
+56
View File
@@ -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
View File
@@ -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
}
+12 -5
View File
@@ -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")
}
+3 -2
View File
@@ -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,
+3 -2
View File
@@ -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;
+3 -2
View File
@@ -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,