Fix release matching for featured artists and credit annotations
ci / module boundaries / boundaries (push) Successful in 1m11s
ci / api / api (push) Successful in 1m54s
ci / mobile / mobile (Metro-бандл Android) (push) Failing after 6m50s
ci / ts / ts (packages + web) (push) Failing after 6m19s
ci / db / db (push) Failing after 11s
ci / module boundaries / boundaries (push) Successful in 1m11s
ci / api / api (push) Successful in 1m54s
ci / mobile / mobile (Metro-бандл Android) (push) Failing after 6m50s
ci / ts / ts (packages + web) (push) Failing after 6m19s
ci / db / db (push) Failing after 11s
This commit is contained in:
1 parent
f4681da876
commit
fd17c06397
7 files changed
+146
-12
No files matched your search
@@ -382,7 +382,7 @@ test('mixed batch checks each release and applies only within the chosen group',
|
|||||||
await expect(page.locator('html')).toHaveAttribute('data-release-applied', /"group_id":"bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"/);
|
await expect(page.locator('html')).toHaveAttribute('data-release-applied', /"group_id":"bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"/);
|
||||||
await dialog.getByRole('combobox', { name: 'Релиз в пачке' }).selectOption('aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa');
|
await dialog.getByRole('combobox', { name: 'Релиз в пачке' }).selectOption('aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa');
|
||||||
await expect(dialog.getByText('Artist A — Album A', { exact: true })).toBeVisible();
|
await expect(dialog.getByText('Artist A — Album A', { exact: true })).toBeVisible();
|
||||||
await expect(dialog.getByText('Совпало 1 из 1', { exact: false })).toBeVisible();
|
await expect(dialog.getByText('Сопоставлено 1 из 1', { exact: false })).toBeVisible();
|
||||||
await expect(dialog.getByRole('button', { name: 'Применить выбранное', exact: true })).toBeDisabled();
|
await expect(dialog.getByRole('button', { name: 'Применить выбранное', exact: true })).toBeDisabled();
|
||||||
await expect(dialog.getByRole('checkbox', { name: /Применить названия/ })).not.toBeChecked();
|
await expect(dialog.getByRole('checkbox', { name: /Применить названия/ })).not.toBeChecked();
|
||||||
});
|
});
|
||||||
@@ -446,8 +446,8 @@ test('deluxe completeness and release-group artwork are shown before replacing a
|
|||||||
await page.getByRole('button', { name: 'Найдено изданий: 2', exact: true }).click();
|
await page.getByRole('button', { name: 'Найдено изданий: 2', exact: true }).click();
|
||||||
const dialog = page.getByRole('dialog', { name: 'Сопоставление релиза' });
|
const dialog = page.getByRole('dialog', { name: 'Сопоставление релиза' });
|
||||||
await dialog.getByRole('combobox', { name: 'Издание' }).selectOption('22222222-2222-4222-8222-222222222222');
|
await dialog.getByRole('combobox', { name: 'Издание' }).selectOption('22222222-2222-4222-8222-222222222222');
|
||||||
await expect(dialog.getByText('Альбом · 2/3 · Неполный состав', { exact: true })).toBeVisible();
|
await expect(dialog.getByText('Альбом · сопоставлено 2/3 · Неполный состав', { exact: true })).toBeVisible();
|
||||||
await expect(dialog.getByText('Не загружено', { exact: true })).toBeVisible();
|
await expect(dialog.getByText('Файл не сопоставлен', { exact: true })).toBeVisible();
|
||||||
await expect(dialog.getByText(/найдены совпадения в библиотеке/)).toBeVisible();
|
await expect(dialog.getByText(/найдены совпадения в библиотеке/)).toBeVisible();
|
||||||
await dialog.getByRole('button', { name: 'Показать обложку из каталога', exact: true }).click();
|
await dialog.getByRole('button', { name: 'Показать обложку из каталога', exact: true }).click();
|
||||||
await expect(dialog.getByText(/Обложка группы изданий/)).toBeVisible();
|
await expect(dialog.getByText(/Обложка группы изданий/)).toBeVisible();
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ export function BatchRelease({ files, onGroupsChange }: { files: UploadFile[]; o
|
|||||||
});
|
});
|
||||||
const kind = (value: string) => value === 'Album' ? t('release_lookup.album') : value === 'Single' ? t('release_lookup.single') : value === 'EP' ? 'EP' : value || t('release_lookup.release');
|
const kind = (value: string) => value === 'Album' ? t('release_lookup.album') : value === 'Single' ? t('release_lookup.single') : value === 'EP' ? 'EP' : value || t('release_lookup.release');
|
||||||
const stateText = needsGroup ? t('release_lookup.groups_count', { count: groups.length }) : report.data?.state === 'ready'
|
const stateText = needsGroup ? t('release_lookup.groups_count', { count: groups.length }) : report.data?.state === 'ready'
|
||||||
? candidate ? `${kind(candidate.release.type)} · ${candidate.matched}/${candidate.total} · ${t(candidate.complete ? 'release_lookup.complete' : candidate.confidence === 'high' && candidate.missing.length > 0 && !candidate.unmatched.length ? 'release_lookup.incomplete' : 'release_lookup.partial')}`
|
? candidate ? `${kind(candidate.release.type)} · ${t('release_lookup.matched_count', { matched: candidate.matched, total: candidate.total })} · ${t(candidate.complete ? 'release_lookup.complete' : candidate.confidence === 'high' && candidate.missing.length > 0 && !candidate.unmatched.length ? 'release_lookup.incomplete' : 'release_lookup.partial')}`
|
||||||
: t('release_lookup.variants', { count: report.data.candidates.length })
|
: t('release_lookup.variants', { count: report.data.candidates.length })
|
||||||
: t(`release_lookup.state_${report.data?.state ?? 'searching'}`);
|
: t(`release_lookup.state_${report.data?.state ?? 'searching'}`);
|
||||||
|
|
||||||
|
|||||||
@@ -195,6 +195,14 @@
|
|||||||
совпадения отмечаются отдельно. Для отсутствующих дорожек отдельно показаны совпадения с доступной
|
совпадения отмечаются отдельно. Для отсутствующих дорожек отдельно показаны совпадения с доступной
|
||||||
библиотекой по тегам и длительности; они не делают загруженную пачку полной.
|
библиотекой по тегам и длительности; они не делают загруженную пачку полной.
|
||||||
|
|
||||||
|
Число файлов в пачке и число сопоставленных дорожек — разные показатели. «Файл не сопоставлен»
|
||||||
|
не доказывает отсутствие дорожки: она может быть среди файлов с отличающимися тегами. Для
|
||||||
|
предположительного совпадения допускается разная запись состава исполнителей (запятая, `feat.`, `;`)
|
||||||
|
и пометки `feat.` / `Prod.` в конце названия при совпадении номера, диска и длительности с допуском
|
||||||
|
2 секунды. Пометки версий (`live`, `remix`, `instrumental`) сохраняются. Такие совпадения требуют
|
||||||
|
проверки и сами по себе не подтверждают «Полный состав». При изменении правил сопоставления
|
||||||
|
сохранённые результаты поиска пересчитываются при открытии пачки.
|
||||||
|
|
||||||
Поиск сам не меняет теги, путь или обложку. В диалоге можно отдельно применить метаданные к совпавшим
|
Поиск сам не меняет теги, путь или обложку. В диалоге можно отдельно применить метаданные к совпавшим
|
||||||
редактируемым файлам и/или обложку. Обложка загружается из Cover Art Archive только после запроса
|
редактируемым файлам и/или обложку. Обложка загружается из Cover Art Archive только после запроса
|
||||||
предпросмотра. При отсутствии обложки конкретного издания возможна обложка группы изданий с явной пометкой.
|
предпросмотра. При отсутствии обложки конкретного издания возможна обложка группы изданий с явной пометкой.
|
||||||
|
|||||||
@@ -42,12 +42,13 @@
|
|||||||
"confidence_high": "high",
|
"confidence_high": "high",
|
||||||
"confidence_medium": "medium",
|
"confidence_medium": "medium",
|
||||||
"confidence_low": "low",
|
"confidence_low": "low",
|
||||||
"coverage": "Matched {{matched}} of {{total}} tracks · Missing: {{missing}} · Unmatched files: {{extra}}",
|
"coverage": "Matched {{matched}} of {{total}} tracks · Unmatched tracks: {{missing}} · Unmatched files: {{extra}}",
|
||||||
"existing": "Metadata and duration matches were found in the library for {{count}} missing tracks. This does not make the uploaded batch complete.",
|
"existing": "Metadata and duration matches were found in the library for {{count}} missing tracks. This does not make the uploaded batch complete.",
|
||||||
"uploaded": "Your upload",
|
"uploaded": "Your upload",
|
||||||
"catalogue": "Selected edition",
|
"catalogue": "Selected edition",
|
||||||
"matched": "File matched",
|
"matched": "File matched",
|
||||||
"missing": "Not uploaded",
|
"matched_count": "matched {{matched}}/{{total}}",
|
||||||
|
"missing": "No matched file",
|
||||||
"tentative": "Tentative match — check manually",
|
"tentative": "Tentative match — check manually",
|
||||||
"apply_metadata": "Apply titles, artists, album, year and numbering to matched files",
|
"apply_metadata": "Apply titles, artists, album, year and numbering to matched files",
|
||||||
"preview_cover": "Preview catalogue cover",
|
"preview_cover": "Preview catalogue cover",
|
||||||
|
|||||||
@@ -42,12 +42,13 @@
|
|||||||
"confidence_high": "высокая",
|
"confidence_high": "высокая",
|
||||||
"confidence_medium": "средняя",
|
"confidence_medium": "средняя",
|
||||||
"confidence_low": "низкая",
|
"confidence_low": "низкая",
|
||||||
"coverage": "Совпало {{matched}} из {{total}} дорожек · Не хватает: {{missing}} · Не сопоставлено файлов: {{extra}}",
|
"coverage": "Сопоставлено {{matched}} из {{total}} дорожек · Дорожек без сопоставления: {{missing}} · Не сопоставлено файлов: {{extra}}",
|
||||||
"existing": "Для {{count}} отсутствующих дорожек найдены совпадения в библиотеке по тегам и длительности. Это не делает загруженную пачку полной.",
|
"existing": "Для {{count}} отсутствующих дорожек найдены совпадения в библиотеке по тегам и длительности. Это не делает загруженную пачку полной.",
|
||||||
"uploaded": "Ваша загрузка",
|
"uploaded": "Ваша загрузка",
|
||||||
"catalogue": "В выбранном издании",
|
"catalogue": "В выбранном издании",
|
||||||
"matched": "Файл сопоставлен",
|
"matched": "Файл сопоставлен",
|
||||||
"missing": "Не загружено",
|
"matched_count": "сопоставлено {{matched}}/{{total}}",
|
||||||
|
"missing": "Файл не сопоставлен",
|
||||||
"tentative": "Предположительное совпадение — проверьте вручную",
|
"tentative": "Предположительное совпадение — проверьте вручную",
|
||||||
"apply_metadata": "Применить названия, исполнителей, альбом, год и нумерацию к совпавшим файлам",
|
"apply_metadata": "Применить названия, исполнителей, альбом, год и нумерацию к совпавшим файлам",
|
||||||
"preview_cover": "Показать обложку из каталога",
|
"preview_cover": "Показать обложку из каталога",
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"math"
|
"math"
|
||||||
|
"regexp"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@@ -70,7 +71,11 @@ func releaseHash(files []File) string {
|
|||||||
entries = append(entries, entry{f.ID, f.Meta, f.DurationMs, f.Tags})
|
entries = append(entries, entry{f.ID, f.Meta, f.DurationMs, f.Tags})
|
||||||
}
|
}
|
||||||
sort.Slice(entries, func(i, j int) bool { return entries[i].ID.String() < entries[j].ID.String() })
|
sort.Slice(entries, func(i, j int) bool { return entries[i].ID.String() < entries[j].ID.String() })
|
||||||
b, _ := json.Marshal(entries)
|
// Invalidate persisted reports when matching rules change, as well as tags.
|
||||||
|
b, _ := json.Marshal(struct {
|
||||||
|
Version int
|
||||||
|
Files []entry
|
||||||
|
}{2, entries})
|
||||||
h := sha256.Sum256(b)
|
h := sha256.Sum256(b)
|
||||||
return hex.EncodeToString(h[:])
|
return hex.EncodeToString(h[:])
|
||||||
}
|
}
|
||||||
@@ -111,6 +116,39 @@ func batchReleaseQuery(files []File) (musicbrainz.Query, bool) {
|
|||||||
}
|
}
|
||||||
return q, q.Artist != ""
|
return q, q.Artist != ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Only credit annotations are removed for tentative matching. Version labels
|
||||||
|
// such as live, remix or instrumental remain significant.
|
||||||
|
var releaseTitleCredits = regexp.MustCompile(`(?i)\s*[\(\[]\s*(?:feat\.?|ft\.?|featuring|prod\.?|produced by)\s+[^()\[\]]+[\)\]]\s*$`)
|
||||||
|
|
||||||
|
func releaseTitleKey(title string) string {
|
||||||
|
for {
|
||||||
|
cleaned := releaseTitleCredits.ReplaceAllString(title, "")
|
||||||
|
if cleaned == title {
|
||||||
|
return meta.Key(title)
|
||||||
|
}
|
||||||
|
title = cleaned
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func sameReleaseArtists(a, b string) bool {
|
||||||
|
left := meta.SplitArtists(strings.ToLower(a), nil)
|
||||||
|
right := meta.SplitArtists(strings.ToLower(b), nil)
|
||||||
|
if len(left) == 0 || len(left) != len(right) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
credits := make(map[string]bool, len(left))
|
||||||
|
for _, artist := range left {
|
||||||
|
credits[meta.Key(artist.Name)] = true
|
||||||
|
}
|
||||||
|
for _, artist := range right {
|
||||||
|
if !credits[meta.Key(artist.Name)] {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
func trackScore(f File, t musicbrainz.Track, albumArtist string) int {
|
func trackScore(f File, t musicbrainz.Track, albumArtist string) int {
|
||||||
if f.Meta == nil {
|
if f.Meta == nil {
|
||||||
return 0
|
return 0
|
||||||
@@ -137,8 +175,9 @@ func trackScore(f File, t musicbrainz.Track, albumArtist string) int {
|
|||||||
if title && (t.DurationMs == 0 || f.DurationMs == nil) {
|
if title && (t.DurationMs == 0 || f.DurationMs == nil) {
|
||||||
return 70
|
return 70
|
||||||
}
|
}
|
||||||
if delta <= 2000 && m.Track != nil && *m.Track == t.Position && (m.Disc == nil && t.Disc == 1 || m.Disc != nil && *m.Disc == t.Disc) && meta.Key(m.Artist) == meta.Key(t.Artist) {
|
if delta <= 2000 && m.Track != nil && *m.Track == t.Position && (m.Disc == nil && t.Disc == 1 || m.Disc != nil && *m.Disc == t.Disc) &&
|
||||||
return 60
|
releaseTitleKey(m.Title) != "" && releaseTitleKey(m.Title) == releaseTitleKey(t.Title) && sameReleaseArtists(m.Artist, t.Artist) {
|
||||||
|
return 75
|
||||||
}
|
}
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ func TestReleaseMatchingDoesNotClaimAmbiguousOrWeakMatchesAreComplete(t *testing
|
|||||||
f := releaseFile("Untitled", 1, 1, 12000)
|
f := releaseFile("Untitled", 1, 1, 12000)
|
||||||
c = matchRelease([]File{f}, r)
|
c = matchRelease([]File{f}, r)
|
||||||
require.False(t, c.Complete)
|
require.False(t, c.Complete)
|
||||||
require.Equal(t, "medium", c.Confidence)
|
require.Empty(t, c.Matches, "number and duration alone do not identify a differently titled recording")
|
||||||
f.Meta.Title = "Intro"
|
f.Meta.Title = "Intro"
|
||||||
f.Meta.Artist = "Someone Else"
|
f.Meta.Artist = "Someone Else"
|
||||||
f.Meta.AlbumArtist = "Someone Else"
|
f.Meta.AlbumArtist = "Someone Else"
|
||||||
@@ -93,3 +93,88 @@ func TestReleaseFingerprintInvalidatesEditsButNotApproval(t *testing.T) {
|
|||||||
files[0].Status = StatusWithdrawn
|
files[0].Status = StatusWithdrawn
|
||||||
require.NotEqual(t, hash, releaseHash(files))
|
require.NotEqual(t, hash, releaseHash(files))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestReleaseMatchesOldBloodCreditsWithoutClaimingVerifiedCompleteness(t *testing.T) {
|
||||||
|
tracks := []struct {
|
||||||
|
title, uploaded, guest string
|
||||||
|
seconds int
|
||||||
|
}{
|
||||||
|
{"Введение", "Введение", "", 34},
|
||||||
|
{"Ни много ни мало", "Ни много ни мало (Prod. Blacksurfer)", "", 143},
|
||||||
|
{"MOONWORK", "MOONWORK (feat. Basic Boy) (Prod. SP4K)", "Basic Boy", 214},
|
||||||
|
{"Angry Toy$", "Angry toy$ (Prod. Ray Qwa)", "", 127},
|
||||||
|
{"Возраст", "Возраст (feat. Artur Kreem) (Prod. Babyboi)", "Artur Kreem", 138},
|
||||||
|
{"OLD BLOOD", "OLD BLOOD (Prod. Chivaz)", "", 138},
|
||||||
|
{"Умы", "Умы (feat. DopeVvs) (Prod. MRKBVNGXR)", "DopeVvs", 125},
|
||||||
|
{"NO HOOK NO HOES", "NO HOOK NO HOES (feat. Yung Hurn) (Prod. Pretty Scream)", "Yung Hurn", 133},
|
||||||
|
{"ART HOES", "ART HOES (Prod. DavidRema)", "", 183},
|
||||||
|
{"Friendly Fire", "Friendly Fire (Prod. Avepack)", "", 122},
|
||||||
|
{"X2", "X2 (Prod. Gredy)", "", 176},
|
||||||
|
{"4 шага", "4 шага (Prod. DavidRema)", "", 135},
|
||||||
|
}
|
||||||
|
r := musicbrainz.Release{Artist: "Boulevard Depo", Title: "OLD BLOOD"}
|
||||||
|
files := []File{}
|
||||||
|
for i, track := range tracks {
|
||||||
|
artist, uploadedArtist := "Boulevard Depo", "BOULEVARD DEPO"
|
||||||
|
if track.guest != "" {
|
||||||
|
artist += " feat. " + track.guest
|
||||||
|
uploadedArtist += ", " + track.guest
|
||||||
|
}
|
||||||
|
r.Tracks = append(r.Tracks, musicbrainz.Track{Title: track.title, Artist: artist, Disc: 1, Position: i + 1, DurationMs: track.seconds * 1000})
|
||||||
|
f := releaseFile(track.uploaded, i+1, 1, track.seconds*1000+500)
|
||||||
|
f.Meta.Artist, f.Meta.AlbumArtist, f.Meta.Album = uploadedArtist, "BOULEVARD DEPO", "OLD BLOOD"
|
||||||
|
files = append([]File{f}, files...)
|
||||||
|
}
|
||||||
|
c := matchRelease(files, r)
|
||||||
|
require.Equal(t, 12, c.Matched)
|
||||||
|
require.Empty(t, c.Missing)
|
||||||
|
require.Empty(t, c.Unmatched)
|
||||||
|
require.False(t, c.Complete, "credit-normalized matches still require review")
|
||||||
|
require.Equal(t, "medium", c.Confidence)
|
||||||
|
for _, match := range c.Matches {
|
||||||
|
require.Equal(t, files[len(files)-1-match.TrackIndex].ID, match.FileID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReleaseCreditMatchingRequiresTitleArtistsPositionAndDuration(t *testing.T) {
|
||||||
|
for _, change := range []string{"none", "different guest", "different title", "remix", "live", "instrumental", "disc", "position", "duration", "unknown duration"} {
|
||||||
|
t.Run(change, func(t *testing.T) {
|
||||||
|
f := releaseFile("Song (feat. Guest) (Prod. Producer)", 3, 1, 180500)
|
||||||
|
f.Meta.Artist = "ARTIST; Guest"
|
||||||
|
r := musicbrainz.Release{Artist: "Artist", Tracks: []musicbrainz.Track{{Title: "Song", Artist: "Artist feat. Guest", Disc: 1, Position: 3, DurationMs: 180000}}}
|
||||||
|
switch change {
|
||||||
|
case "different guest":
|
||||||
|
f.Meta.Artist = "Artist, Someone Else"
|
||||||
|
case "different title":
|
||||||
|
f.Meta.Title = "Another song (Prod. Producer)"
|
||||||
|
f.Meta.Artist = r.Tracks[0].Artist
|
||||||
|
case "remix":
|
||||||
|
f.Meta.Title = "Song (Remix) (feat. Guest)"
|
||||||
|
f.Meta.Artist = r.Tracks[0].Artist
|
||||||
|
case "live":
|
||||||
|
f.Meta.Title = "Song [Live] (feat. Guest)"
|
||||||
|
f.Meta.Artist = r.Tracks[0].Artist
|
||||||
|
case "instrumental":
|
||||||
|
f.Meta.Title = "Song (Instrumental) (feat. Guest)"
|
||||||
|
f.Meta.Artist = r.Tracks[0].Artist
|
||||||
|
case "disc":
|
||||||
|
*f.Meta.Disc = 2
|
||||||
|
case "position":
|
||||||
|
*f.Meta.Track = 4
|
||||||
|
case "duration":
|
||||||
|
*f.DurationMs = 190000
|
||||||
|
case "unknown duration":
|
||||||
|
f.DurationMs = nil
|
||||||
|
}
|
||||||
|
c := matchRelease([]File{f}, r)
|
||||||
|
if change == "none" {
|
||||||
|
require.Equal(t, 1, c.Matched)
|
||||||
|
require.Equal(t, "medium", c.Matches[0].Confidence)
|
||||||
|
} else {
|
||||||
|
require.Zero(t, c.Matched)
|
||||||
|
require.Equal(t, []uuid.UUID{f.ID}, c.Unmatched)
|
||||||
|
}
|
||||||
|
require.False(t, c.Complete)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user