#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 dbContextFactory) : IRequestHandler> { // 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> Handle( GetAllMediaSourcesForApi request, CancellationToken cancellationToken) { await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken); List mediaSources = await dbContext.MediaSources .AsNoTracking() .Include(s => s.Libraries) .ThenInclude(l => l.Paths) .ToListAsync(cancellationToken); Dictionary itemCountsByLibrary = await GetItemCountsByLibrary(dbContext, cancellationToken); Dictionary addressByMediaSourceId = await GetConnectionAddresses(dbContext, cancellationToken); var result = new List(); foreach (MediaSource mediaSource in mediaSources) { List 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> 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> GetConnectionAddresses( TvContext dbContext, CancellationToken cancellationToken) { var addresses = new Dictionary(); 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" }; }