diff --git a/LANCommander.Server.Services/Abstractions/IMediaGrabberService.cs b/LANCommander.Server.Services/Abstractions/IMediaGrabberService.cs index 83a098f3..000d5886 100644 --- a/LANCommander.Server.Services/Abstractions/IMediaGrabberService.cs +++ b/LANCommander.Server.Services/Abstractions/IMediaGrabberService.cs @@ -8,7 +8,7 @@ namespace LANCommander.Server.Services.Abstractions { string Name { get; } MediaType[] SupportedMediaTypes { get; } - Task> SearchAsync(MediaType type, string keywords); + Task> SearchAsync(MediaType type, string keywords, int page = 0); Task DownloadAsync(MediaGrabberResult result); Task DownloadAsync(MediaGrabberResult result, IProgress? progress) @@ -16,16 +16,24 @@ namespace LANCommander.Server.Services.Abstractions IEnumerable GetGrabberNames() => [Name]; + /// + /// 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. + /// + bool SupportsPaging => false; + + IEnumerable GetPagingGrabberNames() => SupportsPaging ? [Name] : []; + async IAsyncEnumerable> 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> 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); } } diff --git a/LANCommander.Server.Services/MediaGrabbers/CompositeMediaGrabberService.cs b/LANCommander.Server.Services/MediaGrabbers/CompositeMediaGrabberService.cs index 6245e0b5..117a5651 100644 --- a/LANCommander.Server.Services/MediaGrabbers/CompositeMediaGrabberService.cs +++ b/LANCommander.Server.Services/MediaGrabbers/CompositeMediaGrabberService.cs @@ -22,11 +22,11 @@ namespace LANCommander.Server.Services.MediaGrabbers _grabbers = new List { hq, steam, steamGridDb, youtube }; } - public async Task> SearchAsync(MediaType type, string keywords) + public async Task> SearchAsync(MediaType type, string keywords, int page = 0) { var results = new List(); - 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 GetGrabberNames() => _grabbers.Select(g => g.Name); + public IEnumerable GetPagingGrabberNames() => _grabbers.Where(g => g.SupportsPaging).Select(g => g.Name); + public async IAsyncEnumerable> 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> 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; diff --git a/LANCommander.Server.Services/MediaGrabbers/HqMediaGrabber.cs b/LANCommander.Server.Services/MediaGrabbers/HqMediaGrabber.cs index cad02457..cb907ccd 100644 --- a/LANCommander.Server.Services/MediaGrabbers/HqMediaGrabber.cs +++ b/LANCommander.Server.Services/MediaGrabbers/HqMediaGrabber.cs @@ -36,8 +36,13 @@ public class HqMediaGrabber( { MediaType.Manual, HqModels.MediaType.Manual }, }; - public async Task> SearchAsync(MediaType type, string keywords) + public async Task> 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 []; diff --git a/LANCommander.Server.Services/MediaGrabbers/SteamGridDBMediaGrabber.cs b/LANCommander.Server.Services/MediaGrabbers/SteamGridDBMediaGrabber.cs index d2e47cbd..b1a4f926 100644 --- a/LANCommander.Server.Services/MediaGrabbers/SteamGridDBMediaGrabber.cs +++ b/LANCommander.Server.Services/MediaGrabbers/SteamGridDBMediaGrabber.cs @@ -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> SearchAsync(MediaType type, string keywords) + public async Task> SearchAsync(MediaType type, string keywords, int page = 0) { var results = new List(); @@ -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> GetIconsAsync(SteamGridDb SteamGridDb, SteamGridDbGame game) + private async Task> GetIconsAsync(SteamGridDb SteamGridDb, SteamGridDbGame game, int page) { var results = new List(); - 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> GetCoversAsync(SteamGridDb SteamGridDb, SteamGridDbGame game) + private async Task> GetCoversAsync(SteamGridDb SteamGridDb, SteamGridDbGame game, int page) { var results = new List(); - 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> GetBackgroundsAsync(SteamGridDb SteamGridDb, SteamGridDbGame game) + private async Task> GetBackgroundsAsync(SteamGridDb SteamGridDb, SteamGridDbGame game, int page) { var results = new List(); - 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> GetLogosAsync(SteamGridDb SteamGridDb, SteamGridDbGame game) + private async Task> GetLogosAsync(SteamGridDb SteamGridDb, SteamGridDbGame game, int page) { var results = new List(); - 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> GetGridsAsync(SteamGridDb SteamGridDb, SteamGridDbGame game) + private async Task> GetGridsAsync(SteamGridDb SteamGridDb, SteamGridDbGame game, int page) { var results = new List(); 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() { diff --git a/LANCommander.Server.Services/MediaGrabbers/SteamMediaGrabber.cs b/LANCommander.Server.Services/MediaGrabbers/SteamMediaGrabber.cs index 7c8f1613..e55fc280 100644 --- a/LANCommander.Server.Services/MediaGrabbers/SteamMediaGrabber.cs +++ b/LANCommander.Server.Services/MediaGrabbers/SteamMediaGrabber.cs @@ -28,8 +28,13 @@ namespace LANCommander.Server.Services.MediaGrabbers SteamClient = new SteamClient(); } - public async Task> SearchAsync(MediaType type, string keywords) + public async Task> 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(); + return type switch { MediaType.Cover => await GetCoversAsync(keywords), diff --git a/LANCommander.Server.Services/MediaGrabbers/YouTubeMediaGrabber.cs b/LANCommander.Server.Services/MediaGrabbers/YouTubeMediaGrabber.cs index 192076c9..bae42621 100644 --- a/LANCommander.Server.Services/MediaGrabbers/YouTubeMediaGrabber.cs +++ b/LANCommander.Server.Services/MediaGrabbers/YouTubeMediaGrabber.cs @@ -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> SearchAsync(MediaType type, string keywords) + private const int PageSize = 20; + + public async Task> SearchAsync(MediaType type, string keywords, int page = 0) { var youtube = new YoutubeClient(); var results = new List(); 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(); diff --git a/LANCommander.Server/Styles/_media-grabber.scss b/LANCommander.Server/Styles/_media-grabber.scss index 649ef5dc..e34bb4f8 100644 --- a/LANCommander.Server/Styles/_media-grabber.scss +++ b/LANCommander.Server/Styles/_media-grabber.scss @@ -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; diff --git a/LANCommander.Server/UI/Components/MediaGrabberDialog.razor b/LANCommander.Server/UI/Components/MediaGrabberDialog.razor index 647b24bf..841a3b58 100644 --- a/LANCommander.Server/UI/Components/MediaGrabberDialog.razor +++ b/LANCommander.Server/UI/Components/MediaGrabberDialog.razor @@ -57,8 +57,16 @@ {
-

@grabberGroup.Group

- via @grabberGroup.GrabberName +
+

@grabberGroup.Group

+ via @grabberGroup.GrabberName +
+ @if (MultiSelect && grabberGroup.Results.Any()) + { + + }
@if (Type == MediaType.Video) @@ -90,6 +98,18 @@ { } + + @if (CanLoadMore(grabberGroup)) + { +
+ +
+ }
} @@ -135,6 +155,12 @@ CancellationTokenSource _searchCts; List _allResults = new(); List _groupedResults = new(); + HashSet _seenKeys = new(); + + HashSet _pagingProviders = new(); + Dictionary _groupPages = new(); + HashSet _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 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;