feat(api): media sources + scan status endpoints (#103 #106) #115

Merged
timothy merged 2 commits from feat/103-media-sources-api into main 2026-07-03 23:10:38 +02:00
16 changed files with 740 additions and 1 deletions
@@ -0,0 +1,5 @@
using ErsatzTV.Core.Api.Libraries;
namespace ErsatzTV.Application.Libraries;
public record GetLibraryScanStatus : IRequest<List<LibraryScanStatusResponseModel>>;
@@ -0,0 +1,20 @@
using ErsatzTV.Core.Api.Libraries;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Metadata;
namespace ErsatzTV.Application.Libraries;
public class GetLibraryScanStatusHandler(IScannerProxyService scannerProxyService)
: IRequestHandler<GetLibraryScanStatus, List<LibraryScanStatusResponseModel>>
{
public Task<List<LibraryScanStatusResponseModel>> Handle(
GetLibraryScanStatus request,
CancellationToken cancellationToken)
{
List<LibraryScanStatusResponseModel> result = scannerProxyService.GetActiveScans()
.Select(scan => new LibraryScanStatusResponseModel(scan.LibraryId, scan.Progress))
.ToList();
return Task.FromResult(result);
}
}
@@ -0,0 +1,5 @@
using ErsatzTV.Core.Api.MediaSources;
namespace ErsatzTV.Application.MediaSources;
public record GetAllMediaSourcesForApi : IRequest<List<MediaSourceResponseModel>>;
@@ -0,0 +1,148 @@
#nullable enable
using Dapper;
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)
{
IEnumerable<LibraryItemCount> counts = await dbContext.Connection.QueryAsync<LibraryItemCount>(
new CommandDefinition(
@"SELECT LP.LibraryId AS LibraryId, COUNT(*) AS Count
FROM MediaItem
INNER JOIN LibraryPath LP on MediaItem.LibraryPathId = LP.Id
GROUP BY LP.LibraryId",
cancellationToken: cancellationToken));
return counts.ToDictionary(c => (int)c.LibraryId, c => (int)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"
};
private sealed record LibraryItemCount(long LibraryId, long Count);
}
@@ -0,0 +1,3 @@
namespace ErsatzTV.Core.Api.Libraries;
public record LibraryScanStatusResponseModel(int LibraryId, decimal Percent);
@@ -0,0 +1,11 @@
#nullable enable
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Core.Api.MediaSources;
public record MediaSourceLibraryResponseModel(
int Id,
string Name,
LibraryMediaKind MediaKind,
DateTime? LastScan,
int ItemCount);
@@ -0,0 +1,9 @@
#nullable enable
namespace ErsatzTV.Core.Api.MediaSources;
public record MediaSourceResponseModel(
int Id,
string Kind,
string Name,
string? ConnectionAddress,
List<MediaSourceLibraryResponseModel> Libraries);
@@ -1,3 +1,5 @@
using ErsatzTV.Core.Metadata;
namespace ErsatzTV.Core.Interfaces.Metadata;
public interface IScannerProxyService
@@ -7,4 +9,5 @@ public interface IScannerProxyService
Task Progress(Guid scanId, decimal percentComplete);
bool IsActive(Guid scanId);
Option<decimal> GetProgress(int libraryId);
IReadOnlyList<LibraryScanProgress> GetActiveScans();
}
@@ -49,4 +49,7 @@ public class ScannerProxyService(IMediator mediator) : IScannerProxyService
public Option<decimal> GetProgress(int libraryId) => _activeLibraries.TryGetValue(libraryId, out decimal progress)
? progress
: Option<decimal>.None;
public IReadOnlyList<LibraryScanProgress> GetActiveScans() =>
_activeLibraries.Select(kvp => new LibraryScanProgress(kvp.Key, kvp.Value)).ToList();
}
@@ -0,0 +1,32 @@
using ErsatzTV.Application.Libraries;
using ErsatzTV.Core.Api.Libraries;
using ErsatzTV.Core.Metadata;
using MediatR;
using NSubstitute;
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Tests.Application.Libraries;
[TestFixture]
public class GetLibraryScanStatusHandlerTests
{
[Test]
public async Task Handle_Should_Return_Active_Scans()
{
var mediator = Substitute.For<IMediator>();
var scannerProxyService = new ScannerProxyService(mediator);
Guid scanId = scannerProxyService.StartScan(42)
.Match(
Some: id => id,
None: () => throw new AssertionException("Expected scan to start"));
await scannerProxyService.Progress(scanId, 62.5m);
var handler = new GetLibraryScanStatusHandler(scannerProxyService);
List<LibraryScanStatusResponseModel> result =
await handler.Handle(new GetLibraryScanStatus(), CancellationToken.None);
result.ShouldBe([new LibraryScanStatusResponseModel(42, 62.5m)]);
}
}
@@ -0,0 +1,179 @@
using ErsatzTV.Application.MediaSources;
using ErsatzTV.Core.Domain;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Tests.Support;
using Microsoft.EntityFrameworkCore;
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Tests.Application.MediaSources;
[TestFixture]
public class GetAllMediaSourcesForApiHandlerTests
{
private InMemoryTvContext _db = null!;
[SetUp]
public async Task SetUp() => _db = await InMemoryTvContext.CreateAsync();
[TearDown]
public async Task TearDown() => await _db.DisposeAsync();
[Test]
public async Task Handle_Should_Group_Configured_Libraries_By_Source_With_Item_Counts()
{
await SeedMediaSources();
var handler = new GetAllMediaSourcesForApiHandler(_db.Factory);
var result = await handler.Handle(new GetAllMediaSourcesForApi(), CancellationToken.None);
result.Count.ShouldBe(4);
result[0].Kind.ShouldBe("Local");
result[0].Name.ShouldBe("Local");
result[0].ConnectionAddress.ShouldBeNull();
result[0].Libraries.Single().Name.ShouldBe("Local Movies");
result[0].Libraries.Single().LastScan.ShouldBe(new DateTime(2026, 7, 1, 0, 0, 0, DateTimeKind.Utc));
result[0].Libraries.Single().ItemCount.ShouldBe(2);
result[1].Kind.ShouldBe("Emby");
result[1].Name.ShouldBe("Emby Server");
result[1].ConnectionAddress.ShouldBe("http://emby.local");
result[1].Libraries.Single().Name.ShouldBe("Emby Shows");
result[1].Libraries.Single().ItemCount.ShouldBe(1);
result[2].Kind.ShouldBe("Jellyfin");
result[2].Name.ShouldBe("Jellyfin Server");
result[2].ConnectionAddress.ShouldBe("http://jellyfin.local");
result[2].Libraries.ShouldBeEmpty();
result[3].Kind.ShouldBe("Plex");
result[3].Name.ShouldBe("Plex Server");
result[3].ConnectionAddress.ShouldBe("http://plex.local");
result[3].Libraries.Single().Name.ShouldBe("Plex Movies");
result[3].Libraries.Single().ItemCount.ShouldBe(0);
}
[Test]
public async Task Handle_Should_Exclude_Unconfigured_And_Not_Synced_Libraries()
{
await SeedMediaSources();
var handler = new GetAllMediaSourcesForApiHandler(_db.Factory);
var result = await handler.Handle(new GetAllMediaSourcesForApi(), CancellationToken.None);
result.SelectMany(s => s.Libraries).Select(l => l.Name)
.ShouldNotContain("Empty Local");
result.SelectMany(s => s.Libraries).Select(l => l.Name)
.ShouldNotContain("Disabled Jellyfin");
}
private async Task SeedMediaSources()
{
await using TvContext context = _db.CreateContext();
var localSource = new LocalMediaSource
{
Libraries =
[
new LocalLibrary
{
Name = "Local Movies",
MediaKind = LibraryMediaKind.Movies,
LastScan = new DateTime(2026, 7, 1, 0, 0, 0, DateTimeKind.Utc),
Paths = [MakePath("/media/movies", 2)]
},
new LocalLibrary
{
Name = "Empty Local",
MediaKind = LibraryMediaKind.Shows,
Paths = []
}
]
};
var plexSource = new PlexMediaSource
{
ServerName = "Plex Server",
ProductVersion = "1",
Platform = "Linux",
PlatformVersion = "1",
ClientIdentifier = "plex",
Connections = [new PlexConnection { IsActive = true, Uri = "http://plex.local" }],
PathReplacements = [],
Libraries =
[
new PlexLibrary
{
Name = "Plex Movies",
MediaKind = LibraryMediaKind.Movies,
Key = "1",
ShouldSyncItems = true,
Paths = []
}
]
};
var embySource = new EmbyMediaSource
{
ServerName = "Emby Server",
OperatingSystem = "Linux",
Connections = [new EmbyConnection { Address = "http://emby.local" }],
PathReplacements = [],
Libraries =
[
new EmbyLibrary
{
Name = "Emby Shows",
MediaKind = LibraryMediaKind.Shows,
ItemId = "emby-shows",
ShouldSyncItems = true,
PathInfos = [],
Paths = [MakePath("/emby/shows", 1)]
}
]
};
var jellyfinSource = new JellyfinMediaSource
{
ServerName = "Jellyfin Server",
OperatingSystem = "Linux",
Connections = [new JellyfinConnection { Address = "http://jellyfin.local" }],
PathReplacements = [],
Libraries =
[
new JellyfinLibrary
{
Name = "Disabled Jellyfin",
MediaKind = LibraryMediaKind.Movies,
ItemId = "jellyfin-movies",
ShouldSyncItems = false,
PathInfos = [],
Paths = [MakePath("/jellyfin/movies", 1)]
}
]
};
context.MediaSources.AddRange(localSource, plexSource, embySource, jellyfinSource);
await context.SaveChangesAsync();
context.ChangeTracker.Clear();
}
private static LibraryPath MakePath(string path, int mediaItemCount) =>
new()
{
Path = path,
LibraryFolders = [],
MediaItems = Enumerable.Range(0, mediaItemCount)
.Select(_ => new Movie
{
MovieMetadata = [],
MediaVersions = [],
Collections = [],
CollectionItems = [],
TraktListItems = []
})
.Cast<MediaItem>()
.ToList()
};
}
@@ -0,0 +1,55 @@
using System.Reflection;
using ErsatzTV.Application.Libraries;
using ErsatzTV.Controllers.Api;
using ErsatzTV.Core.Api.Libraries;
using ErsatzTV.Core.Interfaces.Repositories;
using MediatR;
using Microsoft.AspNetCore.Mvc.Routing;
using NSubstitute;
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Tests.Controllers;
[TestFixture]
public class LibrariesControllerTests
{
private LibrariesController _controller = null!;
private IMediator _mediator = null!;
[SetUp]
public void SetUp()
{
_mediator = Substitute.For<IMediator>();
_controller = new LibrariesController(Substitute.For<ITelevisionRepository>(), _mediator);
}
[Test]
public void ScanStatus_Should_Expose_Idiomatic_Rest_Route()
{
MethodInfo action = typeof(LibrariesController).GetMethod(nameof(LibrariesController.GetScanStatus))
?? throw new AssertionException("Missing action GetScanStatus");
HttpMethodAttribute attribute = action.GetCustomAttributes<HttpMethodAttribute>(inherit: true).Single();
attribute.HttpMethods.ShouldContain("GET");
attribute.Template.ShouldBe("/api/libraries/scan-status");
attribute.Name.ShouldBe("GetLibraryScanStatus");
}
[Test]
public async Task GetScanStatus_Should_Return_Results_From_Mediator()
{
var expected = new List<LibraryScanStatusResponseModel>
{
new(1, 42.5m),
new(2, 99m)
};
_mediator.Send(Arg.Any<GetLibraryScanStatus>(), Arg.Any<CancellationToken>())
.Returns(expected);
List<LibraryScanStatusResponseModel> result = await _controller.GetScanStatus(CancellationToken.None);
result.ShouldBe(expected);
}
}
@@ -0,0 +1,59 @@
using System.Reflection;
using ErsatzTV.Application.MediaSources;
using ErsatzTV.Controllers.Api;
using ErsatzTV.Core.Api.MediaSources;
using ErsatzTV.Core.Domain;
using MediatR;
using Microsoft.AspNetCore.Mvc.Routing;
using NSubstitute;
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Tests.Controllers;
[TestFixture]
public class MediaSourcesControllerTests
{
private MediaSourcesController _controller = null!;
private IMediator _mediator = null!;
[SetUp]
public void SetUp()
{
_mediator = Substitute.For<IMediator>();
_controller = new MediaSourcesController(_mediator);
}
[Test]
public void Controller_Should_Expose_Idiomatic_Rest_Route()
{
MethodInfo action = typeof(MediaSourcesController).GetMethod(nameof(MediaSourcesController.GetAll))
?? throw new AssertionException("Missing action GetAll");
HttpMethodAttribute attribute = action.GetCustomAttributes<HttpMethodAttribute>(inherit: true).Single();
attribute.HttpMethods.ShouldContain("GET");
attribute.Template.ShouldBe("/api/media-sources");
attribute.Name.ShouldBe("GetMediaSources");
}
[Test]
public async Task GetAll_Should_Return_Results_From_Mediator()
{
var expected = new List<MediaSourceResponseModel>
{
new(
1,
"Local",
"Local",
null,
[new MediaSourceLibraryResponseModel(10, "Movies", LibraryMediaKind.Movies, null, 3)])
};
_mediator.Send(Arg.Any<GetAllMediaSourcesForApi>(), Arg.Any<CancellationToken>())
.Returns(expected);
List<MediaSourceResponseModel> result = await _controller.GetAll(CancellationToken.None);
result.ShouldBe(expected);
}
}
@@ -1,14 +1,23 @@
using ErsatzTV.Application.Libraries;
using ErsatzTV.Core.Api.Libraries;
using ErsatzTV.Core.Interfaces.Repositories;
using MediatR;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
namespace ErsatzTV.Controllers.Api;
[ApiController]
[EndpointGroupName("general")]
public class LibrariesController(ITelevisionRepository televisionRepository, IMediator mediator)
public class LibrariesController(ITelevisionRepository televisionRepository, IMediator mediator) : ControllerBase
{
[HttpGet("/api/libraries/scan-status", Name = "GetLibraryScanStatus")]
[Tags("Libraries")]
[EndpointSummary("Get active library scan status")]
[ProducesResponseType(typeof(List<LibraryScanStatusResponseModel>), StatusCodes.Status200OK)]
public async Task<List<LibraryScanStatusResponseModel>> GetScanStatus(CancellationToken cancellationToken) =>
await mediator.Send(new GetLibraryScanStatus(), cancellationToken);
[HttpPost("/api/libraries/{id:int}/scan")]
[Tags("Libraries")]
[EndpointSummary("Scan library")]
@@ -0,0 +1,19 @@
using ErsatzTV.Application.MediaSources;
using ErsatzTV.Core.Api.MediaSources;
using MediatR;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
namespace ErsatzTV.Controllers.Api;
[ApiController]
public class MediaSourcesController(IMediator mediator) : ControllerBase
{
[HttpGet("/api/media-sources", Name = "GetMediaSources")]
[Tags("Media Sources")]
[EndpointSummary("Get all media sources with their libraries")]
[EndpointGroupName("general")]
[ProducesResponseType(typeof(List<MediaSourceResponseModel>), StatusCodes.Status200OK)]
public async Task<List<MediaSourceResponseModel>> GetAll(CancellationToken cancellationToken) =>
await mediator.Send(new GetAllMediaSourcesForApi(), cancellationToken);
}
+179
View File
@@ -1481,6 +1481,46 @@
}
}
},
"/api/libraries/scan-status": {
"get": {
"tags": [
"Libraries"
],
"summary": "Get active library scan status",
"operationId": "GetLibraryScanStatus",
"responses": {
"200": {
"description": "OK",
"content": {
"text/plain": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/LibraryScanStatusResponseModel"
}
}
},
"application/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/LibraryScanStatusResponseModel"
}
}
},
"text/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/LibraryScanStatusResponseModel"
}
}
}
}
}
}
}
},
"/api/libraries/{id}/scan": {
"post": {
"tags": [
@@ -1603,6 +1643,46 @@
}
}
},
"/api/media-sources": {
"get": {
"tags": [
"Media Sources"
],
"summary": "Get all media sources with their libraries",
"operationId": "GetMediaSources",
"responses": {
"200": {
"description": "OK",
"content": {
"text/plain": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/MediaSourceResponseModel"
}
}
},
"application/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/MediaSourceResponseModel"
}
}
},
"text/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/MediaSourceResponseModel"
}
}
}
}
}
}
}
},
"/api/playouts": {
"get": {
"tags": [
@@ -4647,6 +4727,35 @@
}
}
},
"LibraryMediaKind": {
"enum": [
"Movies",
"Shows",
"MusicVideos",
"OtherVideos",
"Songs",
"Images",
"RemoteStreams"
],
"type": "string"
},
"LibraryScanStatusResponseModel": {
"required": [
"libraryId",
"percent"
],
"type": "object",
"properties": {
"libraryId": {
"type": "integer",
"format": "int32"
},
"percent": {
"type": "number",
"format": "double"
}
}
},
"MarathonGroupBy": {
"enum": [
"None",
@@ -4729,6 +4838,73 @@
],
"type": "string"
},
"MediaSourceLibraryResponseModel": {
"required": [
"id",
"name",
"mediaKind",
"lastScan",
"itemCount"
],
"type": "object",
"properties": {
"id": {
"type": "integer",
"format": "int32"
},
"name": {
"type": "string"
},
"mediaKind": {
"$ref": "#/components/schemas/LibraryMediaKind"
},
"lastScan": {
"type": [
"null",
"string"
],
"format": "date-time"
},
"itemCount": {
"type": "integer",
"format": "int32"
}
}
},
"MediaSourceResponseModel": {
"required": [
"id",
"kind",
"name",
"connectionAddress",
"libraries"
],
"type": "object",
"properties": {
"id": {
"type": "integer",
"format": "int32"
},
"kind": {
"type": "string"
},
"name": {
"type": "string"
},
"connectionAddress": {
"type": [
"null",
"string"
]
},
"libraries": {
"type": "array",
"items": {
"$ref": "#/components/schemas/MediaSourceLibraryResponseModel"
}
}
}
},
"MultiCollectionItemViewModel": {
"required": [
"multiCollectionId",
@@ -6388,6 +6564,9 @@
{
"name": "Maintenance"
},
{
"name": "Media Sources"
},
{
"name": "Playouts"
},