Build ErsatzTV Image / Functional E2E (curl contracts) (push) Has been skipped
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Has been skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Has been skipped
Build ErsatzTV Image / CI image pin matches docker/ci (push) Has been skipped
Build ErsatzTV Image / Docs update reminder (push) Has been skipped
Build ErsatzTV Image / decisions.md append-only (push) Has been skipped
Build CI Toolchain Image / Build & push CI image (push) Failing after 10m41s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 18m20s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 18m31s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 3m58s
Co-authored-by: Timothy <timothy.look@gmail.com> Co-committed-by: Timothy <timothy.look@gmail.com>
157 lines
6.0 KiB
C#
157 lines
6.0 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>>
|
|
{
|
|
// A never-scanned library has a null LastScan at runtime, but historical DB rows still carry the
|
|
// 0001-01-01 MinValue sentinel written by the old Reset_* migrations. Coerce any such residual
|
|
// sentinel to null so the API/MCP surface reports "never scanned" as null (parity with the UI),
|
|
// regardless of DB history or provider. Belt-and-suspenders alongside the NullOutNeverScannedLastScan
|
|
// data migration.
|
|
private static readonly DateTime NeverScannedThreshold = new(2000, 1, 1);
|
|
|
|
private static DateTime? NormalizeLastScan(DateTime? lastScan) =>
|
|
lastScan is { } value && value < NeverScannedThreshold ? null : lastScan;
|
|
|
|
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,
|
|
NormalizeLastScan(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"
|
|
};
|
|
}
|