Skip to content

Commit b4466aa

Browse files
authored
Merge pull request #34231 from bdach/user-tag-search-wip
Add initial support for filtering by user tags in song select
2 parents 6f01115 + 02d54e5 commit b4466aa

File tree

13 files changed

+212
-50
lines changed

13 files changed

+212
-50
lines changed

osu.Game.Tests/NonVisual/Filtering/FilterMatchingTest.cs

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,11 @@ public class FilterMatchingTest
4040
Author = { Username = "The Author" },
4141
Source = "unit tests",
4242
Tags = "look for tags too",
43+
UserTags =
44+
{
45+
"song representation/simple",
46+
"style/clean",
47+
}
4348
},
4449
DifficultyName = "version as well",
4550
Length = 2500,
@@ -292,6 +297,33 @@ public void TestCriteriaMatchingBeatmapIDs(string query, bool filtered)
292297
Assert.AreEqual(filtered, carouselItem.Filtered.Value);
293298
}
294299

300+
[TestCase("simple", false)]
301+
[TestCase("\"style/clean\"", false)]
302+
[TestCase("\"style/clean\"!", false)]
303+
[TestCase("iNiS-style", true)]
304+
[TestCase("\"reading/visually dense\"!", true)]
305+
public void TestCriteriaMatchingUserTags(string query, bool filtered)
306+
{
307+
var beatmap = getExampleBeatmap();
308+
var criteria = new FilterCriteria { UserTag = { SearchTerm = query } };
309+
var carouselItem = new CarouselBeatmap(beatmap);
310+
carouselItem.Filter(criteria);
311+
312+
Assert.AreEqual(filtered, carouselItem.Filtered.Value);
313+
}
314+
315+
[Test]
316+
public void TestBeatmapMustHaveAtLeastOneTagIfUserTagFilterActive()
317+
{
318+
var beatmap = getExampleBeatmap();
319+
var criteria = new FilterCriteria { UserTag = { SearchTerm = "simple" } };
320+
var carouselItem = new CarouselBeatmap(beatmap);
321+
carouselItem.BeatmapInfo.Metadata.UserTags.Clear();
322+
carouselItem.Filter(criteria);
323+
324+
Assert.True(carouselItem.Filtered.Value);
325+
}
326+
295327
[Test]
296328
public void TestCustomRulesetCriteria([Values(null, true, false)] bool? matchCustomCriteria)
297329
{

osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapMetadataWedge.cs

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
using System;
55
using System.Linq;
66
using NUnit.Framework;
7+
using osu.Framework.Graphics;
78
using osu.Framework.Graphics.Containers;
89
using osu.Framework.Testing;
910
using osu.Game.Beatmaps;
@@ -25,9 +26,19 @@ protected override void LoadComplete()
2526
{
2627
base.LoadComplete();
2728

28-
Child = wedge = new BeatmapMetadataWedge
29+
var lookupSource = new RealmPopulatingOnlineLookupSource();
30+
Child = new DependencyProvidingContainer
2931
{
30-
State = { Value = Visibility.Visible },
32+
RelativeSizeAxes = Axes.Both,
33+
CachedDependencies = [(typeof(RealmPopulatingOnlineLookupSource), lookupSource)],
34+
Children =
35+
[
36+
lookupSource,
37+
wedge = new BeatmapMetadataWedge
38+
{
39+
State = { Value = Visibility.Visible },
40+
}
41+
]
3142
};
3243
}
3344

osu.Game/Beatmaps/BeatmapMetadata.cs

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,11 @@
22
// See the LICENCE file in the repository root for full licence text.
33

44
using System;
5+
using System.Collections.Generic;
56
using JetBrains.Annotations;
67
using Newtonsoft.Json;
78
using osu.Game.Models;
9+
using osu.Game.Screens.SelectV2;
810
using osu.Game.Users;
911
using osu.Game.Utils;
1012
using Realms;
@@ -15,10 +17,10 @@ namespace osu.Game.Beatmaps
1517
/// A realm model containing metadata for a beatmap.
1618
/// </summary>
1719
/// <remarks>
18-
/// This is currently stored against each beatmap difficulty, even when it is duplicated.
20+
/// An instance of this object is stored against each beatmap difficulty.
1921
/// It is also provided via <see cref="BeatmapSetInfo"/> for convenience and historical purposes.
20-
/// A future effort could see this converted to an <see cref="EmbeddedObject"/> or potentially de-duped
21-
/// and shared across multiple difficulties in the same set, if required.
22+
/// Note that accessing the metadata via <see cref="BeatmapSetInfo"/> may result in indeterminate results
23+
/// as metadata can meaningfully differ per beatmap in a set.
2224
///
2325
/// Note that difficulty name is not stored in this metadata but in <see cref="BeatmapInfo"/>.
2426
/// </remarks>
@@ -43,6 +45,13 @@ public class BeatmapMetadata : RealmObject, IBeatmapMetadataInfo, IDeepCloneable
4345
[JsonProperty(@"tags")]
4446
public string Tags { get; set; } = string.Empty;
4547

48+
/// <summary>
49+
/// The list of user-voted tags applicable to this beatmap.
50+
/// This information is populated from online sources (<see cref="RealmPopulatingOnlineLookupSource"/>)
51+
/// and can meaningfully differ between beatmaps of a single set.
52+
/// </summary>
53+
public IList<string> UserTags { get; } = null!;
54+
4655
/// <summary>
4756
/// The time in milliseconds to begin playing the track for preview purposes.
4857
/// If -1, the track should begin playing at 40% of its length.

osu.Game/Database/RealmAccess.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,8 +99,9 @@ public class RealmAccess : IDisposable
9999
/// 47 2025-01-21 Remove right mouse button binding for absolute scroll. Never use mouse buttons (or scroll) for global actions.
100100
/// 48 2025-03-19 Clear online status for all qualified beatmaps (some were stuck in a qualified state due to local caching issues).
101101
/// 49 2025-06-10 Reset the LegacyOnlineID to -1 for all scores that have it set to 0 (which is semantically the same) for consistency of handling with OnlineID.
102+
/// 50 2025-07-11 Add UserTags to BeatmapMetadata.
102103
/// </summary>
103-
private const int schema_version = 49;
104+
private const int schema_version = 50;
104105

105106
/// <summary>
106107
/// Lock object which is held during <see cref="BlockAllOperations"/> sections, blocking realm retrieval during blocking periods.

osu.Game/Screens/Select/Carousel/CarouselBeatmap.cs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,15 @@ private bool checkMatch(FilterCriteria criteria)
8383
criteria.Title.Matches(BeatmapInfo.Metadata.TitleUnicode);
8484
match &= !criteria.DifficultyName.HasFilter || criteria.DifficultyName.Matches(BeatmapInfo.DifficultyName);
8585
match &= !criteria.Source.HasFilter || criteria.Source.Matches(BeatmapInfo.Metadata.Source);
86+
87+
if (criteria.UserTag.HasFilter)
88+
{
89+
bool anyTagMatched = false;
90+
foreach (string tag in BeatmapInfo.Metadata.UserTags)
91+
anyTagMatched |= criteria.UserTag.Matches(tag);
92+
match &= anyTagMatched;
93+
}
94+
8695
match &= !criteria.UserStarDifficulty.HasFilter || criteria.UserStarDifficulty.IsInRange(BeatmapInfo.StarRating);
8796

8897
if (!match) return false;

osu.Game/Screens/Select/FilterCriteria.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ public class FilterCriteria
3939
public OptionalTextFilter Title;
4040
public OptionalTextFilter DifficultyName;
4141
public OptionalTextFilter Source;
42+
public OptionalTextFilter UserTag;
4243

4344
public OptionalRange<double> UserStarDifficulty = new OptionalRange<double>
4445
{

osu.Game/Screens/Select/FilterQueryParser.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,9 @@ private static bool tryParseKeywordCriteria(FilterCriteria criteria, string key,
116116
case "source":
117117
return TryUpdateCriteriaText(ref criteria.Source, op, value);
118118

119+
case "tag":
120+
return TryUpdateCriteriaText(ref criteria.UserTag, op, value);
121+
119122
default:
120123
return criteria.RulesetCriteria?.TryParseCustomKeywordCriteria(key, op, value) ?? false;
121124
}

osu.Game/Screens/SelectV2/BeatmapCarouselFilterMatching.cs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,15 @@ private static bool checkCriteriaMatch(BeatmapInfo beatmap, FilterCriteria crite
105105
criteria.Title.Matches(beatmap.Metadata.TitleUnicode);
106106
match &= !criteria.DifficultyName.HasFilter || criteria.DifficultyName.Matches(beatmap.DifficultyName);
107107
match &= !criteria.Source.HasFilter || criteria.Source.Matches(beatmap.Metadata.Source);
108+
109+
if (criteria.UserTag.HasFilter)
110+
{
111+
bool anyTagMatched = false;
112+
foreach (string tag in beatmap.Metadata.UserTags)
113+
anyTagMatched |= criteria.UserTag.Matches(tag);
114+
match &= anyTagMatched;
115+
}
116+
108117
match &= !criteria.UserStarDifficulty.HasFilter || criteria.UserStarDifficulty.IsInRange(beatmap.StarRating);
109118

110119
if (!match) return false;

osu.Game/Screens/SelectV2/BeatmapMetadataWedge.cs

Lines changed: 32 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -2,18 +2,21 @@
22
// See the LICENCE file in the repository root for full licence text.
33

44
using System;
5-
using System.Collections.Generic;
65
using System.Linq;
6+
using System.Threading;
7+
using System.Threading.Tasks;
78
using osu.Framework.Allocation;
89
using osu.Framework.Bindables;
10+
using osu.Framework.Extensions;
911
using osu.Framework.Graphics;
1012
using osu.Framework.Graphics.Containers;
13+
using osu.Framework.Logging;
1114
using osu.Game.Beatmaps;
15+
using osu.Game.Database;
1216
using osu.Game.Graphics.Containers;
1317
using osu.Game.Localisation;
1418
using osu.Game.Online;
1519
using osu.Game.Online.API;
16-
using osu.Game.Online.API.Requests;
1720
using osu.Game.Online.API.Requests.Responses;
1821
using osu.Game.Online.Chat;
1922
using osu.Game.Resources.Localisation.Web;
@@ -51,6 +54,12 @@ public partial class BeatmapMetadataWedge : VisibilityContainer
5154
[Resolved]
5255
private IAPIProvider api { get; set; } = null!;
5356

57+
[Resolved]
58+
private RealmPopulatingOnlineLookupSource onlineLookupSource { get; set; } = null!;
59+
60+
[Resolved]
61+
private RealmAccess realm { get; set; } = null!;
62+
5463
private IBindable<APIState> apiState = null!;
5564

5665
[Resolved]
@@ -314,34 +323,34 @@ private void updateDisplay()
314323
}
315324

316325
private APIBeatmapSet? currentOnlineBeatmapSet;
317-
private GetBeatmapSetRequest? currentRequest;
326+
private CancellationTokenSource? cancellationTokenSource;
327+
private Task<APIBeatmapSet?>? currentFetchTask;
318328

319329
private void refetchBeatmapSet()
320330
{
321331
var beatmapSetInfo = beatmap.Value.BeatmapSetInfo;
322332

323-
currentRequest?.Cancel();
324-
currentRequest = null;
333+
cancellationTokenSource?.Cancel();
325334
currentOnlineBeatmapSet = null;
326335

327336
if (beatmapSetInfo.OnlineID >= 1)
328337
{
329-
// todo: consider introducing a BeatmapSetLookupCache for caching benefits.
330-
currentRequest = new GetBeatmapSetRequest(beatmapSetInfo.OnlineID);
331-
currentRequest.Failure += _ => updateOnlineDisplay();
332-
currentRequest.Success += s =>
338+
cancellationTokenSource = new CancellationTokenSource();
339+
currentFetchTask = onlineLookupSource.GetBeatmapSetAsync(beatmapSetInfo.OnlineID);
340+
currentFetchTask.ContinueWith(t =>
333341
{
334-
currentOnlineBeatmapSet = s;
335-
updateOnlineDisplay();
336-
};
337-
338-
api.Queue(currentRequest);
342+
if (t.IsCompletedSuccessfully)
343+
currentOnlineBeatmapSet = t.GetResultSafely();
344+
if (t.Exception != null)
345+
Logger.Log($"Error when fetching online beatmap set: {t.Exception}", LoggingTarget.Network);
346+
Scheduler.AddOnce(updateOnlineDisplay);
347+
});
339348
}
340349
}
341350

342351
private void updateOnlineDisplay()
343352
{
344-
if (currentRequest?.CompletionState == APIRequestCompletionState.Waiting)
353+
if (currentFetchTask?.IsCompleted == false)
345354
{
346355
genre.Data = null;
347356
language.Data = null;
@@ -379,28 +388,21 @@ private void updateOnlineDisplay()
379388

380389
private void updateUserTags()
381390
{
382-
var beatmapInfo = beatmap.Value.BeatmapInfo;
383-
var onlineBeatmapSet = currentOnlineBeatmapSet;
384-
var onlineBeatmap = onlineBeatmapSet?.Beatmaps.SingleOrDefault(b => b.OnlineID == beatmapInfo.OnlineID);
391+
string[] tags = realm.Run(r =>
392+
{
393+
// need to refetch because `beatmap.Value.BeatmapInfo` is not going to have the latest tags
394+
var refetchedBeatmap = r.Find<BeatmapInfo>(beatmap.Value.BeatmapInfo.ID);
395+
return refetchedBeatmap?.Metadata.UserTags.ToArray() ?? [];
396+
});
385397

386-
if (onlineBeatmap?.TopTags == null || onlineBeatmap.TopTags.Length == 0 || onlineBeatmapSet?.RelatedTags == null)
398+
if (tags.Length == 0)
387399
{
388400
userTags.FadeOut(transition_duration, Easing.OutQuint);
389401
return;
390402
}
391403

392-
var tagsById = onlineBeatmapSet.RelatedTags.ToDictionary(t => t.Id);
393-
string[] userTagsArray = onlineBeatmap.TopTags
394-
.Select(t => (topTag: t, relatedTag: tagsById.GetValueOrDefault(t.TagId)))
395-
.Where(t => t.relatedTag != null)
396-
// see https://github.com/ppy/osu-web/blob/bb3bd2e7c6f84f26066df5ea20a81c77ec9bb60a/resources/js/beatmapsets-show/controller.ts#L103-L106 for sort criteria
397-
.OrderByDescending(t => t.topTag.VoteCount)
398-
.ThenBy(t => t.relatedTag!.Name)
399-
.Select(t => t.relatedTag!.Name)
400-
.ToArray();
401-
402404
userTags.FadeIn(transition_duration, Easing.OutQuint);
403-
userTags.Tags = (userTagsArray, t => songSelect?.Search(t));
405+
userTags.Tags = (tags, t => songSelect?.Search($@"tag=""{t}""!"));
404406
}
405407
}
406408
}

osu.Game/Screens/SelectV2/BeatmapMetadataWedge_MetadataDisplay.cs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -56,12 +56,12 @@ public DateTimeOffset? Date
5656
}
5757
}
5858

59-
public (string[] tags, Action<string> linkAction)? Tags
59+
public (string[] tags, Action<string> searchAction)? Tags
6060
{
6161
set
6262
{
6363
if (value != null)
64-
setTags(value.Value.tags, value.Value.linkAction);
64+
setTags(value.Value.tags, value.Value.searchAction);
6565
else
6666
setLoading();
6767
}
@@ -161,12 +161,12 @@ private void setDate(DateTimeOffset date)
161161
contentDate.Date = date;
162162
}
163163

164-
private void setTags(string[] tags, Action<string> link)
164+
private void setTags(string[] tags, Action<string> searchAction)
165165
{
166166
clear();
167167

168168
contentTags.Tags = tags;
169-
contentTags.Action = link;
169+
contentTags.PerformSearch = searchAction;
170170
}
171171

172172
private void setLoading()

0 commit comments

Comments
 (0)