Add "Select All" and "Load More" buttons to media grabber

Fixes #417
This commit is contained in:
Pat Hartl 2026-06-27 23:13:11 -05:00
parent a6bf885340
commit 2f0e0daa3c
8 changed files with 203 additions and 50 deletions

View file

@ -8,7 +8,7 @@ namespace LANCommander.Server.Services.Abstractions
{
string Name { get; }
MediaType[] SupportedMediaTypes { get; }
Task<IEnumerable<MediaGrabberResult>> SearchAsync(MediaType type, string keywords);
Task<IEnumerable<MediaGrabberResult>> SearchAsync(MediaType type, string keywords, int page = 0);
Task<MediaGrabberDownload> DownloadAsync(MediaGrabberResult result);
Task<MediaGrabberDownload> DownloadAsync(MediaGrabberResult result, IProgress<MediaDownloadProgress>? progress)
@ -16,16 +16,24 @@ namespace LANCommander.Server.Services.Abstractions
IEnumerable<string> GetGrabberNames() => [Name];
/// <summary>
/// Whether this grabber can return additional pages of results for a given game/group.
/// Grabbers that return their full result set in a single request should leave this false.
/// </summary>
bool SupportsPaging => false;
IEnumerable<string> GetPagingGrabberNames() => SupportsPaging ? [Name] : [];
async IAsyncEnumerable<IEnumerable<MediaGrabberResult>> SearchStreamAsync(
MediaType type, string keywords,
MediaType type, string keywords, int page,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
yield return await SearchAsync(type, keywords);
yield return await SearchAsync(type, keywords, page);
}
IAsyncEnumerable<IEnumerable<MediaGrabberResult>> SearchStreamAsync(
MediaType type, string keywords, string? grabberName,
MediaType type, string keywords, string? grabberName, int page,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
=> SearchStreamAsync(type, keywords, cancellationToken);
=> SearchStreamAsync(type, keywords, page, cancellationToken);
}
}

View file

@ -22,11 +22,11 @@ namespace LANCommander.Server.Services.MediaGrabbers
_grabbers = new List<IMediaGrabberService> { hq, steam, steamGridDb, youtube };
}
public async Task<IEnumerable<MediaGrabberResult>> SearchAsync(MediaType type, string keywords)
public async Task<IEnumerable<MediaGrabberResult>> SearchAsync(MediaType type, string keywords, int page = 0)
{
var results = new List<MediaGrabberResult>();
await foreach (var batch in SearchStreamAsync(type, keywords))
await foreach (var batch in SearchStreamAsync(type, keywords, page))
results.AddRange(batch);
return results;
@ -34,16 +34,18 @@ namespace LANCommander.Server.Services.MediaGrabbers
public IEnumerable<string> GetGrabberNames() => _grabbers.Select(g => g.Name);
public IEnumerable<string> GetPagingGrabberNames() => _grabbers.Where(g => g.SupportsPaging).Select(g => g.Name);
public async IAsyncEnumerable<IEnumerable<MediaGrabberResult>> SearchStreamAsync(
MediaType type, string keywords,
MediaType type, string keywords, int page,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
await foreach (var batch in SearchStreamAsync(type, keywords, null, cancellationToken))
await foreach (var batch in SearchStreamAsync(type, keywords, null, page, cancellationToken))
yield return batch;
}
public async IAsyncEnumerable<IEnumerable<MediaGrabberResult>> SearchStreamAsync(
MediaType type, string keywords, string? grabberName,
MediaType type, string keywords, string? grabberName, int page,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
var applicable = _grabbers.Where(g => g.SupportedMediaTypes.Contains(type)).ToList();
@ -60,7 +62,7 @@ namespace LANCommander.Server.Services.MediaGrabbers
{
try
{
var results = (await grabber.SearchAsync(type, keywords)).ToList();
var results = (await grabber.SearchAsync(type, keywords, page)).ToList();
foreach (var result in results)
result.GrabberName = grabber.Name;

View file

@ -36,8 +36,13 @@ public class HqMediaGrabber(
{ MediaType.Manual, HqModels.MediaType.Manual },
};
public async Task<IEnumerable<MediaGrabberResult>> SearchAsync(MediaType type, string keywords)
public async Task<IEnumerable<MediaGrabberResult>> SearchAsync(MediaType type, string keywords, int page = 0)
{
// HQ returns all matching media for a game in a single request,
// so there are no further pages to load.
if (page > 0)
return [];
if (!settingsProvider.CurrentValue.Server.HQ.IsAuthenticated)
return [];

View file

@ -12,6 +12,8 @@ namespace LANCommander.Server.Services.MediaGrabbers
public string Name => "SteamGridDB";
public bool SupportsPaging => true;
public MediaType[] SupportedMediaTypes =>
[
MediaType.Icon,
@ -34,7 +36,7 @@ namespace LANCommander.Server.Services.MediaGrabbers
_settingsProvider = settingsProvider;
}
public async Task<IEnumerable<MediaGrabberResult>> SearchAsync(MediaType type, string keywords)
public async Task<IEnumerable<MediaGrabberResult>> SearchAsync(MediaType type, string keywords, int page = 0)
{
var results = new List<MediaGrabberResult>();
@ -52,23 +54,23 @@ namespace LANCommander.Server.Services.MediaGrabbers
switch (type)
{
case MediaType.Icon:
results.AddRange(await GetIconsAsync(SteamGridDb, game));
results.AddRange(await GetIconsAsync(SteamGridDb, game, page));
break;
case MediaType.Cover:
results.AddRange(await GetCoversAsync(SteamGridDb, game));
results.AddRange(await GetCoversAsync(SteamGridDb, game, page));
break;
case MediaType.Background:
results.AddRange(await GetBackgroundsAsync(SteamGridDb, game));
results.AddRange(await GetBackgroundsAsync(SteamGridDb, game, page));
break;
case MediaType.Logo:
results.AddRange(await GetLogosAsync(SteamGridDb, game));
results.AddRange(await GetLogosAsync(SteamGridDb, game, page));
break;
case MediaType.Grid:
results.AddRange(await GetGridsAsync(SteamGridDb, game));
results.AddRange(await GetGridsAsync(SteamGridDb, game, page));
break;
}
}
@ -97,11 +99,11 @@ namespace LANCommander.Server.Services.MediaGrabbers
};
}
private async Task<IEnumerable<MediaGrabberResult>> GetIconsAsync(SteamGridDb SteamGridDb, SteamGridDbGame game)
private async Task<IEnumerable<MediaGrabberResult>> GetIconsAsync(SteamGridDb SteamGridDb, SteamGridDbGame game, int page)
{
var results = new List<MediaGrabberResult>();
var icons = await SteamGridDb.GetIconsByGameIdAsync(game.Id, types: SteamGridDbTypes.Static);
var icons = await SteamGridDb.GetIconsByGameIdAsync(game.Id, types: SteamGridDbTypes.Static, page: page);
results.AddRange(icons.Where(i => SupportedFormats.Contains(i.Format)).Select(i => new MediaGrabberResult()
{
@ -113,7 +115,7 @@ namespace LANCommander.Server.Services.MediaGrabbers
MimeType = GetMimeType(i.Format)
}));
var animatedIcons = await SteamGridDb.GetIconsByGameIdAsync(game.Id, types: SteamGridDbTypes.Animated);
var animatedIcons = await SteamGridDb.GetIconsByGameIdAsync(game.Id, types: SteamGridDbTypes.Animated, page: page);
results.AddRange(animatedIcons.Where(i => i.Format == SteamGridDbFormats.Png).Select(i => new MediaGrabberResult()
{
@ -128,11 +130,11 @@ namespace LANCommander.Server.Services.MediaGrabbers
return results;
}
private async Task<IEnumerable<MediaGrabberResult>> GetCoversAsync(SteamGridDb SteamGridDb, SteamGridDbGame game)
private async Task<IEnumerable<MediaGrabberResult>> GetCoversAsync(SteamGridDb SteamGridDb, SteamGridDbGame game, int page)
{
var results = new List<MediaGrabberResult>();
var covers = await SteamGridDb.GetGridsByGameIdAsync(game.Id, types: SteamGridDbTypes.Static);
var covers = await SteamGridDb.GetGridsByGameIdAsync(game.Id, types: SteamGridDbTypes.Static, page: page);
results.AddRange(covers.Where(b => SupportedFormats.Contains(b.Format)).Select(b => new MediaGrabberResult()
{
@ -144,7 +146,7 @@ namespace LANCommander.Server.Services.MediaGrabbers
MimeType = GetMimeType(b.Format)
}));
var animatedCovers = await SteamGridDb.GetGridsByGameIdAsync(game.Id, types: SteamGridDbTypes.Animated);
var animatedCovers = await SteamGridDb.GetGridsByGameIdAsync(game.Id, types: SteamGridDbTypes.Animated, page: page);
results.AddRange(animatedCovers.Where(b => b.Format == SteamGridDbFormats.Png).Select(b => new MediaGrabberResult()
{
@ -159,11 +161,11 @@ namespace LANCommander.Server.Services.MediaGrabbers
return results;
}
private async Task<IEnumerable<MediaGrabberResult>> GetBackgroundsAsync(SteamGridDb SteamGridDb, SteamGridDbGame game)
private async Task<IEnumerable<MediaGrabberResult>> GetBackgroundsAsync(SteamGridDb SteamGridDb, SteamGridDbGame game, int page)
{
var results = new List<MediaGrabberResult>();
var backgrounds = await SteamGridDb.GetHeroesByGameIdAsync(game.Id, types: SteamGridDbTypes.Static);
var backgrounds = await SteamGridDb.GetHeroesByGameIdAsync(game.Id, types: SteamGridDbTypes.Static, page: page);
results.AddRange(backgrounds.Where(b => SupportedFormats.Contains(b.Format)).Select(b => new MediaGrabberResult()
{
@ -175,7 +177,7 @@ namespace LANCommander.Server.Services.MediaGrabbers
MimeType = GetMimeType(b.Format)
}));
var animatedBackgrounds = await SteamGridDb.GetHeroesByGameIdAsync(game.Id, types: SteamGridDbTypes.Animated);
var animatedBackgrounds = await SteamGridDb.GetHeroesByGameIdAsync(game.Id, types: SteamGridDbTypes.Animated, page: page);
results.AddRange(animatedBackgrounds.Where(b => b.Format == SteamGridDbFormats.Png).Select(b => new MediaGrabberResult()
{
@ -190,11 +192,11 @@ namespace LANCommander.Server.Services.MediaGrabbers
return results;
}
private async Task<IEnumerable<MediaGrabberResult>> GetLogosAsync(SteamGridDb SteamGridDb, SteamGridDbGame game)
private async Task<IEnumerable<MediaGrabberResult>> GetLogosAsync(SteamGridDb SteamGridDb, SteamGridDbGame game, int page)
{
var results = new List<MediaGrabberResult>();
var logos = await SteamGridDb.GetLogosByGameIdAsync(game.Id, types: SteamGridDbTypes.Static);
var logos = await SteamGridDb.GetLogosByGameIdAsync(game.Id, types: SteamGridDbTypes.Static, page: page);
results.AddRange(logos.Where(b => SupportedFormats.Contains(b.Format)).Select(b => new MediaGrabberResult()
{
@ -206,7 +208,7 @@ namespace LANCommander.Server.Services.MediaGrabbers
MimeType = GetMimeType(b.Format)
}));
var animatedLogos = await SteamGridDb.GetLogosByGameIdAsync(game.Id, types: SteamGridDbTypes.Animated);
var animatedLogos = await SteamGridDb.GetLogosByGameIdAsync(game.Id, types: SteamGridDbTypes.Animated, page: page);
results.AddRange(animatedLogos.Where(b => b.Format == SteamGridDbFormats.Png).Select(b => new MediaGrabberResult()
{
@ -221,14 +223,15 @@ namespace LANCommander.Server.Services.MediaGrabbers
return results;
}
private async Task<IEnumerable<MediaGrabberResult>> GetGridsAsync(SteamGridDb SteamGridDb, SteamGridDbGame game)
private async Task<IEnumerable<MediaGrabberResult>> GetGridsAsync(SteamGridDb SteamGridDb, SteamGridDbGame game, int page)
{
var results = new List<MediaGrabberResult>();
var grids = await SteamGridDb.GetGridsByGameIdAsync(
game.Id,
dimensions: SteamGridDbDimensions.W460H215 | SteamGridDbDimensions.W920H430,
types: SteamGridDbTypes.Static);
types: SteamGridDbTypes.Static,
page: page);
results.AddRange(grids.Where(g => SupportedFormats.Contains(g.Format)).Select(g => new MediaGrabberResult()
{
@ -243,7 +246,8 @@ namespace LANCommander.Server.Services.MediaGrabbers
var animatedGrids = await SteamGridDb.GetGridsByGameIdAsync(
game.Id,
dimensions: SteamGridDbDimensions.W460H215 | SteamGridDbDimensions.W920H430,
types: SteamGridDbTypes.Animated);
types: SteamGridDbTypes.Animated,
page: page);
results.AddRange(animatedGrids.Where(g => g.Format == SteamGridDbFormats.Png).Select(g => new MediaGrabberResult()
{

View file

@ -28,8 +28,13 @@ namespace LANCommander.Server.Services.MediaGrabbers
SteamClient = new SteamClient();
}
public async Task<IEnumerable<MediaGrabberResult>> SearchAsync(MediaType type, string keywords)
public async Task<IEnumerable<MediaGrabberResult>> SearchAsync(MediaType type, string keywords, int page = 0)
{
// Steam returns the full set of assets for a game in a single request,
// so there are no further pages to load.
if (page > 0)
return Enumerable.Empty<MediaGrabberResult>();
return type switch
{
MediaType.Cover => await GetCoversAsync(keywords),

View file

@ -13,15 +13,19 @@ namespace LANCommander.Server.Services.MediaGrabbers
{
public string Name => "YouTube";
public bool SupportsPaging => true;
public MediaType[] SupportedMediaTypes => [MediaType.Video];
public async Task<IEnumerable<MediaGrabberResult>> SearchAsync(MediaType type, string keywords)
private const int PageSize = 20;
public async Task<IEnumerable<MediaGrabberResult>> SearchAsync(MediaType type, string keywords, int page = 0)
{
var youtube = new YoutubeClient();
var results = new List<MediaGrabberResult>();
var group = $"{keywords.Trim()}: Trailers & Gameplay";
foreach (var video in (await youtube.Search.GetVideosAsync(keywords + " trailer gameplay")).Take(20))
foreach (var video in (await youtube.Search.GetVideosAsync(keywords + " trailer gameplay")).Skip(page * PageSize).Take(PageSize))
{
var thumbnail = video.Thumbnails.GetWithHighestResolution();

View file

@ -3,6 +3,11 @@
}
.media-grabber-group-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
h2 {
margin: 0;
}
@ -28,6 +33,12 @@
padding: 24px 0;
}
.media-grabber-load-more {
display: flex;
justify-content: center;
padding: 12px 0;
}
// Video preview
.media-grabber-preview {
position: relative;

View file

@ -57,8 +57,16 @@
{
<div class="media-grabber-group">
<div class="media-grabber-group-header">
<h2>@grabberGroup.Group</h2>
<span class="media-grabber-group-source">via @grabberGroup.GrabberName</span>
<div class="media-grabber-group-title">
<h2>@grabberGroup.Group</h2>
<span class="media-grabber-group-source">via @grabberGroup.GrabberName</span>
</div>
@if (MultiSelect && grabberGroup.Results.Any())
{
<Button Size="ButtonSize.Small" OnClick="() => ToggleSelectAll(grabberGroup)">
@(AllSelected(grabberGroup) ? "Deselect all" : "Select all")
</Button>
}
</div>
@if (Type == MediaType.Video)
@ -90,6 +98,18 @@
{
<ImagePicker Size="Size" MultiSelect="MultiSelect" SelectedValues="_selectedKeys" SelectedValuesChanged="OnMultiImageSelectionChanged" Images="@grabberGroup.Results.ToDictionary(r => r.UniqueKey, r => r.ThumbnailUrl)" ValueChanged="OnImageSelected" OnPicked="OnImagePicked" />
}
@if (CanLoadMore(grabberGroup))
{
<div class="media-grabber-load-more">
<Button Size="ButtonSize.Small"
Loading="@(_loadingGroupId == GroupId(grabberGroup))"
Disabled="@(_loadingGroupId != null)"
OnClick="() => LoadMoreGroup(grabberGroup)">
Load more
</Button>
</div>
}
</div>
}
@ -135,6 +155,12 @@
CancellationTokenSource _searchCts;
List<MediaGrabberResult> _allResults = new();
List<GroupedResults> _groupedResults = new();
HashSet<string> _seenKeys = new();
HashSet<string> _pagingProviders = new();
Dictionary<string, int> _groupPages = new();
HashSet<string> _exhaustedGroups = new();
string _loadingGroupId;
string _selectedProvider = string.Empty;
string[] _availableProviders = [];
@ -147,6 +173,7 @@
_availableProviders = new[] { "All" }.Concat(MediaGrabberService.GetGrabberNames()).ToArray();
_selectedProvider = _availableProviders.First();
_pagingProviders = MediaGrabberService.GetPagingGrabberNames().ToHashSet();
await GetResults(Type, Search);
}
@ -171,7 +198,11 @@
_preview = null;
_allResults.Clear();
_groupedResults.Clear();
_seenKeys.Clear();
_selectedKeys.Clear();
_groupPages.Clear();
_exhaustedGroups.Clear();
_loadingGroupId = null;
await InvokeAsync(StateHasChanged);
if (String.IsNullOrWhiteSpace(search))
@ -179,18 +210,7 @@
var grabberName = _selectedProvider == "All" ? null : _selectedProvider;
await foreach (var batch in MediaGrabberService.SearchStreamAsync(type, search, grabberName, token))
{
if (token.IsCancellationRequested)
break;
foreach (var result in batch)
result.UniqueKey = $"{result.GrabberName}:{result.Id}";
_allResults.AddRange(batch);
RebuildGroups();
await InvokeAsync(StateHasChanged);
}
await StreamResults(type, search, grabberName, 0, null, token);
Loading = false;
await InvokeAsync(StateHasChanged);
@ -205,6 +225,79 @@
}
}
private string GroupId(GroupedResults group) => $"{group.GrabberName}|{group.Group}";
private bool CanLoadMore(GroupedResults group) =>
!Loading
&& group.Results.Any()
&& _pagingProviders.Contains(group.GrabberName)
&& !_exhaustedGroups.Contains(GroupId(group));
private async Task LoadMoreGroup(GroupedResults group)
{
if (Loading || _loadingGroupId != null || String.IsNullOrWhiteSpace(Search))
return;
var groupId = GroupId(group);
_searchCts ??= new CancellationTokenSource();
var token = _searchCts.Token;
try
{
_loadingGroupId = groupId;
await InvokeAsync(StateHasChanged);
var nextPage = _groupPages.GetValueOrDefault(groupId) + 1;
_groupPages[groupId] = nextPage;
// Only query the provider this group came from, and keep only this game's results.
var added = await StreamResults(Type, Search, group.GrabberName, nextPage, group.Group, token);
if (added == 0)
_exhaustedGroups.Add(groupId);
_loadingGroupId = null;
await InvokeAsync(StateHasChanged);
}
catch (OperationCanceledException) { }
catch (Exception ex)
{
_loadingGroupId = null;
MessageService.Error("An unknown error occurred when trying to get more media results");
Logger.LogError(ex, "An unknown error occurred when trying to get more media results");
}
}
private async Task<int> StreamResults(MediaType type, string search, string? grabberName, int page, string? onlyGroup, CancellationToken token)
{
var added = 0;
await foreach (var batch in MediaGrabberService.SearchStreamAsync(type, search, grabberName, page, token))
{
if (token.IsCancellationRequested)
break;
foreach (var result in batch)
{
if (onlyGroup != null && result.Group != onlyGroup)
continue;
result.UniqueKey = $"{result.GrabberName}:{result.Id}";
if (!_seenKeys.Add(result.UniqueKey))
continue;
_allResults.Add(result);
added++;
}
RebuildGroups();
await InvokeAsync(StateHasChanged);
}
return added;
}
private void RebuildGroups()
{
_groupedResults = _allResults
@ -229,6 +322,27 @@
}
}
private bool AllSelected(GroupedResults group) =>
group.Results.Count > 0 && group.Results.All(r => _selectedKeys.Contains(r.UniqueKey));
private async Task ToggleSelectAll(GroupedResults group)
{
var keys = group.Results.Select(r => r.UniqueKey).ToList();
if (keys.All(_selectedKeys.Contains))
{
foreach (var key in keys)
_selectedKeys.Remove(key);
}
else
{
foreach (var key in keys)
_selectedKeys.Add(key);
}
await InvokeAsync(StateHasChanged);
}
private async Task OnResultPicked(MediaGrabberResult result)
{
Media = result;