diff --git a/apps/web/e2e/upload-review.spec.ts b/apps/web/e2e/upload-review.spec.ts index 5c6573d..5ae2304 100644 --- a/apps/web/e2e/upload-review.spec.ts +++ b/apps/web/e2e/upload-review.spec.ts @@ -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 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('Совпало 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('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(); const dialog = page.getByRole('dialog', { name: 'Сопоставление релиза' }); await dialog.getByRole('combobox', { name: 'Издание' }).selectOption('22222222-2222-4222-8222-222222222222'); - await expect(dialog.getByText('Альбом · 2/3 · Неполный состав', { exact: true })).toBeVisible(); - await expect(dialog.getByText('Не загружено', { exact: true })).toBeVisible(); + await expect(dialog.getByText('Альбом · сопоставлено 2/3 · Неполный состав', { exact: true })).toBeVisible(); + await expect(dialog.getByText('Файл не сопоставлен', { exact: true })).toBeVisible(); await expect(dialog.getByText(/найдены совпадения в библиотеке/)).toBeVisible(); await dialog.getByRole('button', { name: 'Показать обложку из каталога', exact: true }).click(); await expect(dialog.getByText(/Обложка группы изданий/)).toBeVisible(); diff --git a/apps/web/src/features/uploads/BatchRelease.tsx b/apps/web/src/features/uploads/BatchRelease.tsx index e504fb0..504ba2a 100644 --- a/apps/web/src/features/uploads/BatchRelease.tsx +++ b/apps/web/src/features/uploads/BatchRelease.tsx @@ -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 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.state_${report.data?.state ?? 'searching'}`); diff --git a/docs/uploads.md b/docs/uploads.md index da2ca50..4a3cc7c 100644 --- a/docs/uploads.md +++ b/docs/uploads.md @@ -195,6 +195,14 @@ совпадения отмечаются отдельно. Для отсутствующих дорожек отдельно показаны совпадения с доступной библиотекой по тегам и длительности; они не делают загруженную пачку полной. +Число файлов в пачке и число сопоставленных дорожек — разные показатели. «Файл не сопоставлен» +не доказывает отсутствие дорожки: она может быть среди файлов с отличающимися тегами. Для +предположительного совпадения допускается разная запись состава исполнителей (запятая, `feat.`, `;`) +и пометки `feat.` / `Prod.` в конце названия при совпадении номера, диска и длительности с допуском +2 секунды. Пометки версий (`live`, `remix`, `instrumental`) сохраняются. Такие совпадения требуют +проверки и сами по себе не подтверждают «Полный состав». При изменении правил сопоставления +сохранённые результаты поиска пересчитываются при открытии пачки. + Поиск сам не меняет теги, путь или обложку. В диалоге можно отдельно применить метаданные к совпавшим редактируемым файлам и/или обложку. Обложка загружается из Cover Art Archive только после запроса предпросмотра. При отсутствии обложки конкретного издания возможна обложка группы изданий с явной пометкой. diff --git a/packages/i18n/locales/en/admin.json b/packages/i18n/locales/en/admin.json index ed10145..7b7ed1f 100644 --- a/packages/i18n/locales/en/admin.json +++ b/packages/i18n/locales/en/admin.json @@ -42,12 +42,13 @@ "confidence_high": "high", "confidence_medium": "medium", "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.", "uploaded": "Your upload", "catalogue": "Selected edition", "matched": "File matched", - "missing": "Not uploaded", + "matched_count": "matched {{matched}}/{{total}}", + "missing": "No matched file", "tentative": "Tentative match — check manually", "apply_metadata": "Apply titles, artists, album, year and numbering to matched files", "preview_cover": "Preview catalogue cover", diff --git a/packages/i18n/locales/ru/admin.json b/packages/i18n/locales/ru/admin.json index 4cee619..d61748b 100644 --- a/packages/i18n/locales/ru/admin.json +++ b/packages/i18n/locales/ru/admin.json @@ -42,12 +42,13 @@ "confidence_high": "высокая", "confidence_medium": "средняя", "confidence_low": "низкая", - "coverage": "Совпало {{matched}} из {{total}} дорожек · Не хватает: {{missing}} · Не сопоставлено файлов: {{extra}}", + "coverage": "Сопоставлено {{matched}} из {{total}} дорожек · Дорожек без сопоставления: {{missing}} · Не сопоставлено файлов: {{extra}}", "existing": "Для {{count}} отсутствующих дорожек найдены совпадения в библиотеке по тегам и длительности. Это не делает загруженную пачку полной.", "uploaded": "Ваша загрузка", "catalogue": "В выбранном издании", "matched": "Файл сопоставлен", - "missing": "Не загружено", + "matched_count": "сопоставлено {{matched}}/{{total}}", + "missing": "Файл не сопоставлен", "tentative": "Предположительное совпадение — проверьте вручную", "apply_metadata": "Применить названия, исполнителей, альбом, год и нумерацию к совпавшим файлам", "preview_cover": "Показать обложку из каталога", diff --git a/services/api/internal/uploads/release_match.go b/services/api/internal/uploads/release_match.go index bba0fc0..1f0f246 100644 --- a/services/api/internal/uploads/release_match.go +++ b/services/api/internal/uploads/release_match.go @@ -5,6 +5,7 @@ import ( "encoding/hex" "encoding/json" "math" + "regexp" "sort" "strings" @@ -70,7 +71,11 @@ func releaseHash(files []File) string { 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() }) - 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) return hex.EncodeToString(h[:]) } @@ -111,6 +116,39 @@ func batchReleaseQuery(files []File) (musicbrainz.Query, bool) { } 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 { if f.Meta == nil { return 0 @@ -137,8 +175,9 @@ func trackScore(f File, t musicbrainz.Track, albumArtist string) int { if title && (t.DurationMs == 0 || f.DurationMs == nil) { 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) { - return 60 + if delta <= 2000 && m.Track != nil && *m.Track == t.Position && (m.Disc == nil && t.Disc == 1 || m.Disc != nil && *m.Disc == t.Disc) && + releaseTitleKey(m.Title) != "" && releaseTitleKey(m.Title) == releaseTitleKey(t.Title) && sameReleaseArtists(m.Artist, t.Artist) { + return 75 } return 0 } diff --git a/services/api/internal/uploads/release_match_test.go b/services/api/internal/uploads/release_match_test.go index 7c49b93..e37d8d2 100644 --- a/services/api/internal/uploads/release_match_test.go +++ b/services/api/internal/uploads/release_match_test.go @@ -58,7 +58,7 @@ func TestReleaseMatchingDoesNotClaimAmbiguousOrWeakMatchesAreComplete(t *testing f := releaseFile("Untitled", 1, 1, 12000) c = matchRelease([]File{f}, r) 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.Artist = "Someone Else" f.Meta.AlbumArtist = "Someone Else" @@ -93,3 +93,88 @@ func TestReleaseFingerprintInvalidatesEditsButNotApproval(t *testing.T) { files[0].Status = StatusWithdrawn 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) + }) + } +}