GET /api/media-sources threw on any empty database: with zero result rows, Microsoft.Data.Sqlite reports the COUNT(*) column as BLOB, so Dapper generated a (long, byte[]) deserializer that cannot match LibraryItemCount(long, long). Replaced the Dapper query with EF LINQ GroupBy (provider-agnostic, no schema inference) and added an empty-database regression test. Also carries the Scriban.Signed 6.5.2 -> 7.2.5 bump (GHSA-5wr9-m6jw-xx44, PR #137) so this branch builds; redundant once #137 lands on main. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
147 lines
5.3 KiB
C#
147 lines
5.3 KiB
C#
#nullable enable
|
|
using ErsatzTV.Core.Api.MediaSources;
|
|
using ErsatzTV.Core.Domain;
|
|
using ErsatzTV.Infrastructure.Data;
|
|
using Microsoft.EntityFrameworkCore;
|
|
|
|
namespace ErsatzTV.Application.MediaSources;
|
|
|
|
public class GetAllMediaSourcesForApiHandler(
|
|
IDbContextFactory<TvContext> dbContextFactory)
|
|
: IRequestHandler<GetAllMediaSourcesForApi, List<MediaSourceResponseModel>>
|
|
{
|
|
public async Task<List<MediaSourceResponseModel>> Handle(
|
|
GetAllMediaSourcesForApi request,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
|
|
|
List<MediaSource> mediaSources = await dbContext.MediaSources
|
|
.AsNoTracking()
|
|
.Include(s => s.Libraries)
|
|
.ThenInclude(l => l.Paths)
|
|
.ToListAsync(cancellationToken);
|
|
|
|
Dictionary<int, int> itemCountsByLibrary = await GetItemCountsByLibrary(dbContext, cancellationToken);
|
|
Dictionary<int, string> addressByMediaSourceId = await GetConnectionAddresses(dbContext, cancellationToken);
|
|
|
|
var result = new List<MediaSourceResponseModel>();
|
|
foreach (MediaSource mediaSource in mediaSources)
|
|
{
|
|
List<MediaSourceLibraryResponseModel> libraryModels = mediaSource.Libraries
|
|
.Filter(ShouldIncludeLibrary)
|
|
.OrderBy(l => l.MediaKind)
|
|
.ThenBy(l => l.Name)
|
|
.Map(l => new MediaSourceLibraryResponseModel(
|
|
l.Id,
|
|
l.Name,
|
|
l.MediaKind,
|
|
l.LastScan,
|
|
itemCountsByLibrary.TryGetValue(l.Id, out int count) ? count : 0))
|
|
.ToList();
|
|
|
|
string? address = addressByMediaSourceId.TryGetValue(mediaSource.Id, out string? a) ? a : null;
|
|
|
|
result.Add(
|
|
new MediaSourceResponseModel(
|
|
mediaSource.Id,
|
|
GetKind(mediaSource),
|
|
GetName(mediaSource),
|
|
address,
|
|
libraryModels));
|
|
}
|
|
|
|
return result
|
|
.OrderBy(s => s.Kind == "Local" ? 0 : 1)
|
|
.ThenBy(s => s.Kind)
|
|
.ThenBy(s => s.Name)
|
|
.ToList();
|
|
}
|
|
|
|
private static async Task<Dictionary<int, int>> GetItemCountsByLibrary(
|
|
TvContext dbContext,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
// EF instead of Dapper: with zero rows, Microsoft.Data.Sqlite reports the
|
|
// COUNT(*) column as BLOB, so Dapper builds an incompatible deserializer
|
|
// and every fresh (empty) database returned 500 from this endpoint.
|
|
var counts = await dbContext.MediaItems
|
|
.AsNoTracking()
|
|
.GroupBy(mi => mi.LibraryPath.LibraryId)
|
|
.Select(g => new { LibraryId = g.Key, Count = g.Count() })
|
|
.ToListAsync(cancellationToken);
|
|
|
|
return counts.ToDictionary(c => c.LibraryId, c => c.Count);
|
|
}
|
|
|
|
private static async Task<Dictionary<int, string>> GetConnectionAddresses(
|
|
TvContext dbContext,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var addresses = new Dictionary<int, string>();
|
|
|
|
foreach (PlexMediaSource plex in await dbContext.PlexMediaSources
|
|
.AsNoTracking()
|
|
.Include(s => s.Connections)
|
|
.ToListAsync(cancellationToken))
|
|
{
|
|
foreach (PlexConnection connection in Optional(plex.Connections.SingleOrDefault(c => c.IsActive)))
|
|
{
|
|
addresses[plex.Id] = connection.Uri;
|
|
}
|
|
}
|
|
|
|
foreach (JellyfinMediaSource jellyfin in await dbContext.JellyfinMediaSources
|
|
.AsNoTracking()
|
|
.Include(s => s.Connections)
|
|
.ToListAsync(cancellationToken))
|
|
{
|
|
foreach (JellyfinConnection connection in jellyfin.Connections.HeadOrNone())
|
|
{
|
|
addresses[jellyfin.Id] = connection.Address;
|
|
}
|
|
}
|
|
|
|
foreach (EmbyMediaSource emby in await dbContext.EmbyMediaSources
|
|
.AsNoTracking()
|
|
.Include(s => s.Connections)
|
|
.ToListAsync(cancellationToken))
|
|
{
|
|
foreach (EmbyConnection connection in emby.Connections.HeadOrNone())
|
|
{
|
|
addresses[emby.Id] = connection.Address;
|
|
}
|
|
}
|
|
|
|
return addresses;
|
|
}
|
|
|
|
private static bool ShouldIncludeLibrary(Library library) =>
|
|
library switch
|
|
{
|
|
LocalLibrary => library.Paths.Count > 0,
|
|
PlexLibrary plex => plex.ShouldSyncItems,
|
|
JellyfinLibrary jellyfin => jellyfin.ShouldSyncItems,
|
|
EmbyLibrary emby => emby.ShouldSyncItems,
|
|
_ => false
|
|
};
|
|
|
|
private static string GetKind(MediaSource mediaSource) =>
|
|
mediaSource switch
|
|
{
|
|
PlexMediaSource => "Plex",
|
|
JellyfinMediaSource => "Jellyfin",
|
|
EmbyMediaSource => "Emby",
|
|
_ => "Local"
|
|
};
|
|
|
|
private static string GetName(MediaSource mediaSource) =>
|
|
mediaSource switch
|
|
{
|
|
PlexMediaSource plex => plex.ServerName,
|
|
JellyfinMediaSource jellyfin => jellyfin.ServerName,
|
|
EmbyMediaSource emby => emby.ServerName,
|
|
_ => "Local"
|
|
};
|
|
}
|