Skip to content

Commit 1d39734

Browse files
committed
feat: Implement artist lookup functionality with external search integration
1 parent 7da152c commit 1d39734

14 files changed

Lines changed: 932 additions & 12 deletions

File tree

Dockerfile

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ COPY ["Directory.Build.props", "./"]
1010
COPY ["src/Melodee.Blazor/Melodee.Blazor.csproj", "src/Melodee.Blazor/"]
1111
COPY ["src/Melodee.Cli/Melodee.Cli.csproj", "src/Melodee.Cli/"]
1212
COPY ["src/Melodee.Common/Melodee.Common.csproj", "src/Melodee.Common/"]
13+
COPY ["src/Melodee.Mql/Melodee.Mql.csproj", "src/Melodee.Mql/"]
1314

1415
# Restore as distinct layers
1516
RUN dotnet restore "src/Melodee.Blazor/Melodee.Blazor.csproj"
@@ -19,6 +20,7 @@ RUN dotnet restore "src/Melodee.Cli/Melodee.Cli.csproj"
1920
COPY ["src/Melodee.Blazor/", "src/Melodee.Blazor/"]
2021
COPY ["src/Melodee.Cli/", "src/Melodee.Cli/"]
2122
COPY ["src/Melodee.Common/", "src/Melodee.Common/"]
23+
COPY ["src/Melodee.Mql/", "src/Melodee.Mql/"]
2224

2325
WORKDIR "/src/src/Melodee.Blazor"
2426
RUN dotnet build "Melodee.Blazor.csproj" -c Release -o /app/build
Lines changed: 235 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,235 @@
1+
@using Melodee.Blazor.Components.Pages
2+
@using Melodee.Blazor.Controllers.Melodee.Models.ArtistLookup
3+
4+
@inherits MelodeeComponentBase
5+
6+
@inject IHttpClientFactory HttpClientFactory
7+
@inject DialogService DialogService
8+
@inject NotificationService NotificationService
9+
10+
<RadzenStack Gap="1rem">
11+
<RadzenStack Orientation="Orientation.Horizontal" Gap="0.5rem" AlignItems="AlignItems.Center">
12+
<RadzenTextBox @bind-Value="@_searchText"
13+
Placeholder="@L("ArtistLookup.EnterArtistName")"
14+
Style="flex: 1"
15+
Name="SearchText"
16+
@onkeyup="@OnKeyUpAsync"
17+
Disabled="@_isLoading"/>
18+
<RadzenButton Icon="search"
19+
Text="@L("Actions.Search")"
20+
Click="@SearchAsync"
21+
Disabled="@(_isLoading || string.IsNullOrWhiteSpace(_searchText))"
22+
ButtonStyle="ButtonStyle.Primary"/>
23+
</RadzenStack>
24+
25+
@if (_providers != null && _providers.Length > 0)
26+
{
27+
<RadzenStack Orientation="Orientation.Horizontal" Gap="0.5rem" class="rz-mt-2">
28+
<RadzenLabel Text="@L("ArtistLookup.FilterByProvider")" Component="ProviderFilter"/>
29+
<RadzenDropDown @bind-Value="@_selectedProviderIds"
30+
Data="@_providers.Where(p => p.IsEnabled)"
31+
TextProperty="DisplayName"
32+
ValueProperty="Id"
33+
Multiple="true"
34+
Placeholder="@L("ArtistLookup.AllEnabledProviders")"
35+
Style="flex: 1"
36+
Name="ProviderFilter"
37+
AllowFiltering="false"/>
38+
</RadzenStack>
39+
}
40+
41+
@if (_isLoading)
42+
{
43+
<RadzenStack AlignItems="AlignItems.Center" class="rz-p-4">
44+
<RadzenProgressBar Value="100" ShowValue="false" Mode="ProgressBarMode.Indeterminate"/>
45+
<RadzenText Text="@L("ArtistLookup.Searching")" class="rz-text-secondary"/>
46+
</RadzenStack>
47+
}
48+
else if (_hasError)
49+
{
50+
<RadzenAlert AlertStyle="AlertStyle.Danger" ShowIcon="true">
51+
@L("ArtistLookup.SearchError")
52+
</RadzenAlert>
53+
}
54+
else if (_candidates != null && _candidates.Length > 0)
55+
{
56+
<RadzenStack Gap="0.5rem" class="rz-mt-2" Style="max-height: 400px; overflow-y: auto;">
57+
@foreach (var candidate in _candidates)
58+
{
59+
<RadzenCard class="candidate-card" Click="@(() => SelectCandidate(candidate))">
60+
<RadzenStack Gap="0.5rem">
61+
<RadzenStack Orientation="Orientation.Horizontal" Gap="0.5rem" AlignItems="AlignItems.Center">
62+
@if (!string.IsNullOrEmpty(candidate.ThumbnailUrl))
63+
{
64+
<RadzenImage Path="@candidate.ThumbnailUrl"
65+
Style="width: 48px; height: 48px; object-fit: cover;"
66+
AlternateText="@candidate.Name"/>
67+
}
68+
<RadzenStack Style="flex: 1">
69+
<RadzenText TextStyle="TextStyle.Body1" class="rz-font-weight-bold">
70+
@candidate.Name
71+
</RadzenText>
72+
@if (!string.IsNullOrEmpty(candidate.SortName) && candidate.SortName != candidate.Name)
73+
{
74+
<RadzenText TextStyle="TextStyle.Caption" class="rz-text-secondary">
75+
@candidate.SortName
76+
</RadzenText>
77+
}
78+
<RadzenText TextStyle="TextStyle.Caption" class="rz-text-secondary">
79+
@L("ArtistLookup.FromProvider", candidate.ProviderDisplayName)
80+
</RadzenText>
81+
</RadzenStack>
82+
</RadzenStack>
83+
84+
<RadzenStack Orientation="Orientation.Horizontal" Gap="0.25rem" class="rz-mt-1">
85+
@if (candidate.MusicBrainzId.HasValue)
86+
{
87+
<RadzenBadge BadgeStyle="BadgeStyle.Info" Text="@($"MB: {candidate.MusicBrainzId.Value.ToString()[..8]}...")"/>
88+
}
89+
@if (!string.IsNullOrEmpty(candidate.SpotifyId))
90+
{
91+
<RadzenBadge BadgeStyle="BadgeStyle.Success" Text="@($"Spotify: {candidate.SpotifyId}")"/>
92+
}
93+
@if (!string.IsNullOrEmpty(candidate.DiscogsId))
94+
{
95+
<RadzenBadge BadgeStyle="BadgeStyle.Warning" Text="@($"Discogs: {candidate.DiscogsId}")"/>
96+
}
97+
</RadzenStack>
98+
</RadzenStack>
99+
</RadzenCard>
100+
}
101+
</RadzenStack>
102+
}
103+
else if (_hasSearched)
104+
{
105+
<RadzenText Text="@L("ArtistLookup.NoResults")" class="rz-text-secondary rz-text-align-center"/>
106+
}
107+
108+
@if (_hasPartialFailures)
109+
{
110+
<RadzenAlert AlertStyle="AlertStyle.Warning" ShowIcon="true" class="rz-mt-2">
111+
@L("ArtistLookup.SomeProvidersFailed")
112+
</RadzenAlert>
113+
}
114+
115+
<RadzenStack Orientation="Orientation.Horizontal" Gap="0.5rem" JustifyContent="JustifyContent.End" class="rz-mt-4">
116+
<RadzenButton Text="@L("Actions.Cancel")"
117+
ButtonStyle="ButtonStyle.Light"
118+
Click="@(() => DialogService.Close(null))"/>
119+
</RadzenStack>
120+
</RadzenStack>
121+
122+
@code {
123+
private string _searchText = string.Empty;
124+
private ArtistLookupCandidate[]? _candidates;
125+
private ProviderInfo[]? _providers;
126+
private string[] _selectedProviderIds = [];
127+
private bool _isLoading;
128+
private bool _hasError;
129+
private bool _hasSearched;
130+
private bool _hasPartialFailures;
131+
private CancellationTokenSource? _searchCts;
132+
133+
[Parameter]
134+
public string? InitialArtistName { get; set; }
135+
136+
protected override async Task OnInitializedAsync()
137+
{
138+
await base.OnInitializedAsync();
139+
_searchText = InitialArtistName ?? string.Empty;
140+
await LoadProvidersAsync();
141+
}
142+
143+
private async Task LoadProvidersAsync()
144+
{
145+
try
146+
{
147+
var httpClient = HttpClientFactory.CreateClient("MelodeeApi");
148+
var response = await httpClient.GetAsync("api/v1/artist-lookup/providers");
149+
if (response.IsSuccessStatusCode)
150+
{
151+
var content = await response.Content.ReadFromJsonAsync<ProviderInfo[]>();
152+
_providers = content ?? [];
153+
}
154+
}
155+
catch (Exception)
156+
{
157+
_providers = [];
158+
}
159+
}
160+
161+
private async Task OnKeyUpAsync(KeyboardEventArgs args)
162+
{
163+
if (args.Key == "Enter" && !string.IsNullOrWhiteSpace(_searchText))
164+
{
165+
await SearchAsync();
166+
}
167+
}
168+
169+
private async Task SearchAsync()
170+
{
171+
if (string.IsNullOrWhiteSpace(_searchText))
172+
{
173+
return;
174+
}
175+
176+
_searchCts?.Cancel();
177+
_searchCts = new CancellationTokenSource();
178+
179+
_isLoading = true;
180+
_hasError = false;
181+
_hasSearched = true;
182+
_hasPartialFailures = false;
183+
_candidates = null;
184+
185+
try
186+
{
187+
var httpClient = HttpClientFactory.CreateClient("MelodeeApi");
188+
var request = new
189+
{
190+
ArtistName = _searchText,
191+
Limit = 20,
192+
ProviderIds = _selectedProviderIds.Length > 0 ? _selectedProviderIds : null
193+
};
194+
195+
var response = await httpClient.PostAsJsonAsync("api/v1/artist-lookup", request, _searchCts.Token);
196+
if (response.IsSuccessStatusCode)
197+
{
198+
var content = await response.Content.ReadFromJsonAsync<ArtistLookupResponse>(_searchCts.Token);
199+
_candidates = content?.Candidates ?? [];
200+
_hasPartialFailures = content?.HasPartialFailures ?? false;
201+
}
202+
else
203+
{
204+
_hasError = true;
205+
}
206+
}
207+
catch (OperationCanceledException)
208+
{
209+
}
210+
catch (Exception)
211+
{
212+
_hasError = true;
213+
}
214+
finally
215+
{
216+
_isLoading = false;
217+
}
218+
}
219+
220+
private void SelectCandidate(ArtistLookupCandidate candidate)
221+
{
222+
DialogService.Close(candidate);
223+
}
224+
}
225+
226+
<style>
227+
.candidate-card {
228+
cursor: pointer;
229+
transition: background-color 0.2s;
230+
}
231+
232+
.candidate-card:hover {
233+
background-color: var(--rz-base-200);
234+
}
235+
</style>

src/Melodee.Blazor/Components/Pages/Media/ArtistEdit.razor

Lines changed: 54 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@
22
@page "/media/artist/{ArtistId:int}/edit"
33

44
@inherits MelodeeComponentBase
5+
@using Melodee.Blazor.Components.Dialogs
6+
@using Melodee.Blazor.Controllers.Melodee.Models.ArtistLookup
7+
@using Melodee.Common.Models
58
@using Melodee.Common.Services.SearchEngines
69
@using Artist = Melodee.Common.Models.SearchEngines.ArtistSearchEngineServiceData.Artist
710

@@ -265,15 +268,58 @@
265268
{
266269
}
267270

268-
private async Task SearchForExternalButtonClick(string amgid)
271+
private async Task SearchForExternalButtonClick(string fieldName)
269272
{
270-
await DialogService.OpenAsync("External Search", ds =>
271-
@<div>
272-
<RadzenText>External search integration for artist metadata retrieval.</RadzenText>
273-
<RadzenText class="rz-text-secondary">This feature allows searching external providers (iTunes, Last.fm, Spotify) for artist information.</RadzenText>
274-
<RadzenButton Text="Close" Click="@(() => ds.Close())" class="rz-mt-3"/>
275-
</div>,
276-
new DialogOptions { Width = "500px" });
273+
var initialArtistName = !string.IsNullOrEmpty(_artist.Name) ? _artist.Name : string.Empty;
274+
var result = await DialogService.OpenAsync<ArtistLookupDialog>(
275+
L("ArtistLookup.Title"),
276+
new Dictionary<string, object> { { "InitialArtistName", initialArtistName } },
277+
new DialogOptions { Width = "600px", Height = "80vh" });
278+
279+
if (result is ArtistLookupCandidate candidate)
280+
{
281+
ApplyCandidateToArtist(candidate, fieldName);
282+
NotificationService.Notify(new NotificationMessage
283+
{
284+
Severity = NotificationSeverity.Success,
285+
Summary = "Artist Found",
286+
Detail = "Artist information has been updated from the selected provider.",
287+
Duration = ToastTime
288+
});
289+
}
290+
}
291+
292+
private void ApplyCandidateToArtist(ArtistLookupCandidate candidate, string triggeredField)
293+
{
294+
if (!string.IsNullOrEmpty(candidate.AmgId))
295+
{
296+
_artist.AmgId = candidate.AmgId;
297+
}
298+
if (!string.IsNullOrEmpty(candidate.DiscogsId))
299+
{
300+
_artist.DiscogsId = candidate.DiscogsId;
301+
}
302+
if (!string.IsNullOrEmpty(candidate.ItunesId))
303+
{
304+
_artist.ItunesId = candidate.ItunesId;
305+
}
306+
if (!string.IsNullOrEmpty(candidate.LastFmId))
307+
{
308+
_artist.LastFmId = candidate.LastFmId;
309+
}
310+
if (candidate.MusicBrainzId.HasValue)
311+
{
312+
_artist.MusicBrainzId = candidate.MusicBrainzId.Value;
313+
_artist.MusicBrainzIdValue = candidate.MusicBrainzId.Value.ToString();
314+
}
315+
if (!string.IsNullOrEmpty(candidate.SpotifyId))
316+
{
317+
_artist.SpotifyId = candidate.SpotifyId;
318+
}
319+
if (!string.IsNullOrEmpty(candidate.WikiDataId))
320+
{
321+
_artist.WikiDataId = candidate.WikiDataId;
322+
}
277323
}
278324

279325

0 commit comments

Comments
 (0)