feat(api): add collections REST endpoints
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 4m5s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 5m9s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped

Refs #35
This commit is contained in:
2026-06-27 21:43:11 +02:00
parent 8f7d240fc8
commit fb3f2856da
21 changed files with 1250 additions and 69 deletions
@@ -3,6 +3,7 @@ using ErsatzTV.Application.Playouts;
using ErsatzTV.Application.Search; using ErsatzTV.Application.Search;
using ErsatzTV.Core; using ErsatzTV.Core;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Scheduling; using ErsatzTV.Core.Scheduling;
using ErsatzTV.Infrastructure.Data; using ErsatzTV.Infrastructure.Data;
@@ -42,8 +43,15 @@ public class AddItemsToCollectionHandler :
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, Collection> validation = await Validate(dbContext, request, cancellationToken); Option<Collection> maybeCollection = await CollectionMustExist(dbContext, request, cancellationToken);
return await validation.Apply(c => ApplyAddItemsRequest(dbContext, c, request, cancellationToken)); return await maybeCollection.Match(
Some: async collection =>
{
Validation<BaseError, Collection> validation = await Validate(dbContext, request, collection, cancellationToken);
return await validation.Apply(c => ApplyAddItemsRequest(dbContext, c, request, cancellationToken));
},
None: () => Task.FromResult<Either<BaseError, Unit>>(
new NotFoundError($"Collection {request.CollectionId} does not exist.")));
} }
private async Task<Unit> ApplyAddItemsRequest( private async Task<Unit> ApplyAddItemsRequest(
@@ -89,22 +97,23 @@ public class AddItemsToCollectionHandler :
private async Task<Validation<BaseError, Collection>> Validate( private async Task<Validation<BaseError, Collection>> Validate(
TvContext dbContext, TvContext dbContext,
AddItemsToCollection request, AddItemsToCollection request,
Collection collection,
CancellationToken cancellationToken) => CancellationToken cancellationToken) =>
(await CollectionMustExist(dbContext, request, cancellationToken), (await ValidateMovies(request),
await ValidateMovies(request),
await ValidateShows(request), await ValidateShows(request),
await ValidateSeasons(request), await ValidateSeasons(request),
await ValidateEpisodes(request)) await ValidateEpisodes(request),
.Apply((collection, _, _, _, _) => collection); await ValidateMediaItems(dbContext, request, cancellationToken))
.Apply((_, _, _, _, _) => collection);
private static Task<Validation<BaseError, Collection>> CollectionMustExist( private static Task<Option<Collection>> CollectionMustExist(
TvContext dbContext, TvContext dbContext,
AddItemsToCollection request, AddItemsToCollection request,
CancellationToken cancellationToken) => CancellationToken cancellationToken) =>
dbContext.Collections dbContext.Collections
.Include(c => c.MediaItems) .Include(c => c.MediaItems)
.SelectOneAsync(c => c.Id, c => c.Id == request.CollectionId, cancellationToken) .SelectOneAsync(c => c.Id, c => c.Id == request.CollectionId, cancellationToken)
.Map(o => o.ToValidation<BaseError>("Collection does not exist.")); .Map(identity);
private Task<Validation<BaseError, Unit>> ValidateMovies(AddItemsToCollection request) => private Task<Validation<BaseError, Unit>> ValidateMovies(AddItemsToCollection request) =>
_movieRepository.AllMoviesExist(request.MovieIds) _movieRepository.AllMoviesExist(request.MovieIds)
@@ -133,4 +142,30 @@ public class AddItemsToCollectionHandler :
.Filter(v => v == true) .Filter(v => v == true)
.MapT(_ => Unit.Default) .MapT(_ => Unit.Default)
.Map(v => v.ToValidation<BaseError>("Episode does not exist")); .Map(v => v.ToValidation<BaseError>("Episode does not exist"));
private static async Task<Validation<BaseError, Unit>> ValidateMediaItems(
TvContext dbContext,
AddItemsToCollection request,
CancellationToken cancellationToken)
{
List<int> ids = GetRequestedMediaItemIds(request).Distinct().ToList();
int existingCount = await dbContext.MediaItems
.CountAsync(mi => ids.Contains(mi.Id), cancellationToken);
return existingCount == ids.Count
? Unit.Default
: BaseError.New("Media item does not exist");
}
private static IEnumerable<int> GetRequestedMediaItemIds(AddItemsToCollection request) =>
request.MovieIds
.Append(request.ShowIds)
.Append(request.SeasonIds)
.Append(request.EpisodeIds)
.Append(request.ArtistIds)
.Append(request.MusicVideoIds)
.Append(request.OtherVideoIds)
.Append(request.SongIds)
.Append(request.ImageIds)
.Append(request.RemoteStreamIds);
} }
@@ -1,5 +1,6 @@
using ErsatzTV.Core; using ErsatzTV.Core;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Interfaces.Search; using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Infrastructure.Data; using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions; using ErsatzTV.Infrastructure.Extensions;
@@ -23,8 +24,11 @@ public class DeleteCollectionHandler : IRequestHandler<DeleteCollection, Either<
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, Collection> validation = await CollectionMustExist(dbContext, request, cancellationToken); Option<Collection> maybeCollection = await CollectionMustExist(dbContext, request, cancellationToken);
return await validation.Apply(c => DoDeletion(dbContext, c, cancellationToken)); return await maybeCollection.Match(
Some: collection => DoDeletion(dbContext, collection, cancellationToken).Map(Right<BaseError, Unit>),
None: () => Task.FromResult<Either<BaseError, Unit>>(
new NotFoundError($"Collection {request.CollectionId} does not exist.")));
} }
private async Task<Unit> DoDeletion(TvContext dbContext, Collection collection, CancellationToken cancellationToken) private async Task<Unit> DoDeletion(TvContext dbContext, Collection collection, CancellationToken cancellationToken)
@@ -35,11 +39,11 @@ public class DeleteCollectionHandler : IRequestHandler<DeleteCollection, Either<
return Unit.Default; return Unit.Default;
} }
private static Task<Validation<BaseError, Collection>> CollectionMustExist( private static Task<Option<Collection>> CollectionMustExist(
TvContext dbContext, TvContext dbContext,
DeleteCollection request, DeleteCollection request,
CancellationToken cancellationToken) => CancellationToken cancellationToken) =>
dbContext.Collections dbContext.Collections
.SelectOneAsync(c => c.Id, c => c.Id == request.CollectionId, cancellationToken) .SelectOneAsync(c => c.Id, c => c.Id == request.CollectionId, cancellationToken)
.Map(o => o.ToValidation<BaseError>($"Collection {request.CollectionId} does not exist.")); .Map(identity);
} }
@@ -1,5 +1,6 @@
using ErsatzTV.Core; using ErsatzTV.Core;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Interfaces.Search; using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Core.Search; using ErsatzTV.Core.Search;
using ErsatzTV.Infrastructure.Data; using ErsatzTV.Infrastructure.Data;
@@ -29,11 +30,14 @@ public class DeleteSmartCollectionHandler : IRequestHandler<DeleteSmartCollectio
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, SmartCollection> validation = await SmartCollectionMustExist( Option<SmartCollection> maybeSmartCollection = await SmartCollectionMustExist(
dbContext, dbContext,
request, request,
cancellationToken); cancellationToken);
return await validation.Apply(c => DoDeletion(dbContext, c, cancellationToken)); return await maybeSmartCollection.Match(
Some: smartCollection => DoDeletion(dbContext, smartCollection, cancellationToken).Map(Right<BaseError, Unit>),
None: () => Task.FromResult<Either<BaseError, Unit>>(
new NotFoundError($"SmartCollection {request.SmartCollectionId} does not exist.")));
} }
private async Task<Unit> DoDeletion( private async Task<Unit> DoDeletion(
@@ -48,11 +52,11 @@ public class DeleteSmartCollectionHandler : IRequestHandler<DeleteSmartCollectio
return Unit.Default; return Unit.Default;
} }
private static Task<Validation<BaseError, SmartCollection>> SmartCollectionMustExist( private static Task<Option<SmartCollection>> SmartCollectionMustExist(
TvContext dbContext, TvContext dbContext,
DeleteSmartCollection request, DeleteSmartCollection request,
CancellationToken cancellationToken) => CancellationToken cancellationToken) =>
dbContext.SmartCollections dbContext.SmartCollections
.SelectOneAsync(c => c.Id, c => c.Id == request.SmartCollectionId, cancellationToken) .SelectOneAsync(c => c.Id, c => c.Id == request.SmartCollectionId, cancellationToken)
.Map(o => o.ToValidation<BaseError>($"SmartCollection {request.SmartCollectionId} does not exist.")); .Map(identity);
} }
@@ -3,6 +3,7 @@ using ErsatzTV.Application.Playouts;
using ErsatzTV.Application.Search; using ErsatzTV.Application.Search;
using ErsatzTV.Core; using ErsatzTV.Core;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Scheduling; using ErsatzTV.Core.Scheduling;
using ErsatzTV.Infrastructure.Data; using ErsatzTV.Infrastructure.Data;
@@ -35,20 +36,29 @@ public class RemoveItemsFromCollectionHandler : IRequestHandler<RemoveItemsFromC
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, Collection> validation = await Validate(dbContext, request, cancellationToken); Option<Collection> maybeCollection = await CollectionMustExist(dbContext, request, cancellationToken);
return await validation.Apply(c => ApplyRemoveItemsRequest(dbContext, request, c, cancellationToken)); return await maybeCollection.Match(
Some: collection => ApplyRemoveItemsRequest(dbContext, request, collection, cancellationToken),
None: () => Task.FromResult<Either<BaseError, Unit>>(
new NotFoundError($"Collection {request.MediaCollectionId} does not exist.")));
} }
private async Task<Unit> ApplyRemoveItemsRequest( private async Task<Either<BaseError, Unit>> ApplyRemoveItemsRequest(
TvContext dbContext, TvContext dbContext,
RemoveItemsFromCollection request, RemoveItemsFromCollection request,
Collection collection, Collection collection,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
List<int> requestedIds = request.MediaItemIds.Distinct().ToList();
var itemsToRemove = collection.MediaItems var itemsToRemove = collection.MediaItems
.Filter(m => request.MediaItemIds.Contains(m.Id)) .Filter(m => requestedIds.Contains(m.Id))
.ToList(); .ToList();
if (itemsToRemove.Count != requestedIds.Count)
{
return new NotFoundError("Collection item does not exist.");
}
itemsToRemove.ForEach(m => collection.MediaItems.Remove(m)); itemsToRemove.ForEach(m => collection.MediaItems.Remove(m));
if (itemsToRemove.Count != 0 && await dbContext.SaveChangesAsync(cancellationToken) > 0) if (itemsToRemove.Count != 0 && await dbContext.SaveChangesAsync(cancellationToken) > 0)
@@ -67,18 +77,12 @@ public class RemoveItemsFromCollectionHandler : IRequestHandler<RemoveItemsFromC
return Unit.Default; return Unit.Default;
} }
private static Task<Validation<BaseError, Collection>> Validate( private static Task<Option<Collection>> CollectionMustExist(
TvContext dbContext,
RemoveItemsFromCollection request,
CancellationToken cancellationToken) =>
CollectionMustExist(dbContext, request, cancellationToken);
private static Task<Validation<BaseError, Collection>> CollectionMustExist(
TvContext dbContext, TvContext dbContext,
RemoveItemsFromCollection request, RemoveItemsFromCollection request,
CancellationToken cancellationToken) => CancellationToken cancellationToken) =>
dbContext.Collections dbContext.Collections
.Include(c => c.MediaItems) .Include(c => c.MediaItems)
.SelectOneAsync(c => c.Id, c => c.Id == request.MediaCollectionId, cancellationToken) .SelectOneAsync(c => c.Id, c => c.Id == request.MediaCollectionId, cancellationToken)
.Map(o => o.ToValidation<BaseError>("Collection does not exist.")); .Map(identity);
} }
@@ -2,6 +2,7 @@
using ErsatzTV.Application.Playouts; using ErsatzTV.Application.Playouts;
using ErsatzTV.Core; using ErsatzTV.Core;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Search; using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Core.Scheduling; using ErsatzTV.Core.Scheduling;
@@ -35,8 +36,15 @@ public class UpdateCollectionHandler : IRequestHandler<UpdateCollection, Either<
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, Collection> validation = await Validate(dbContext, request, cancellationToken); Option<Collection> maybeCollection = await CollectionMustExist(dbContext, request, cancellationToken);
return await validation.Apply(c => ApplyUpdateRequest(dbContext, c, request, cancellationToken)); return await maybeCollection.Match(
Some: async collection =>
{
Validation<BaseError, Collection> validation = await Validate(dbContext, request, collection);
return await validation.Apply(c => ApplyUpdateRequest(dbContext, c, request, cancellationToken));
},
None: () => Task.FromResult<Either<BaseError, Unit>>(
new NotFoundError($"Collection {request.CollectionId} does not exist.")));
} }
private async Task<Unit> ApplyUpdateRequest( private async Task<Unit> ApplyUpdateRequest(
@@ -69,17 +77,16 @@ public class UpdateCollectionHandler : IRequestHandler<UpdateCollection, Either<
private static async Task<Validation<BaseError, Collection>> Validate( private static async Task<Validation<BaseError, Collection>> Validate(
TvContext dbContext, TvContext dbContext,
UpdateCollection request, UpdateCollection request,
CancellationToken cancellationToken) => Collection collection) =>
(await CollectionMustExist(dbContext, request, cancellationToken), await ValidateName(dbContext, request)) (await ValidateName(dbContext, request)).Map(_ => collection);
.Apply((collectionToUpdate, _) => collectionToUpdate);
private static Task<Validation<BaseError, Collection>> CollectionMustExist( private static Task<Option<Collection>> CollectionMustExist(
TvContext dbContext, TvContext dbContext,
UpdateCollection updateCollection, UpdateCollection updateCollection,
CancellationToken cancellationToken) => CancellationToken cancellationToken) =>
dbContext.Collections dbContext.Collections
.SelectOneAsync(c => c.Id, c => c.Id == updateCollection.CollectionId, cancellationToken) .SelectOneAsync(c => c.Id, c => c.Id == updateCollection.CollectionId, cancellationToken)
.Map(o => o.ToValidation<BaseError>("Collection does not exist.")); .Map(identity);
private static async Task<Validation<BaseError, string>> ValidateName( private static async Task<Validation<BaseError, string>> ValidateName(
TvContext dbContext, TvContext dbContext,
@@ -2,6 +2,7 @@
using ErsatzTV.Application.Playouts; using ErsatzTV.Application.Playouts;
using ErsatzTV.Core; using ErsatzTV.Core;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Search; using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Core.Scheduling; using ErsatzTV.Core.Scheduling;
@@ -41,8 +42,18 @@ public class
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, SmartCollection> validation = await Validate(dbContext, request, cancellationToken); Option<SmartCollection> maybeSmartCollection = await SmartCollectionMustExist(
return await validation.Apply(c => ApplyUpdateRequest(dbContext, c, request, cancellationToken)); dbContext,
request,
cancellationToken);
return await maybeSmartCollection.Match(
Some: async smartCollection =>
{
Validation<BaseError, SmartCollection> validation = await Validate(dbContext, request, smartCollection);
return await validation.Apply(c => ApplyUpdateRequest(dbContext, c, request, cancellationToken));
},
None: () => Task.FromResult<Either<BaseError, UpdateSmartCollectionResult>>(
new NotFoundError($"SmartCollection {request.Id} does not exist.")));
} }
private async Task<UpdateSmartCollectionResult> ApplyUpdateRequest( private async Task<UpdateSmartCollectionResult> ApplyUpdateRequest(
@@ -73,16 +84,16 @@ public class
private static Task<Validation<BaseError, SmartCollection>> Validate( private static Task<Validation<BaseError, SmartCollection>> Validate(
TvContext dbContext, TvContext dbContext,
UpdateSmartCollection request, UpdateSmartCollection request,
CancellationToken cancellationToken) => ValidateName(dbContext, request) SmartCollection smartCollection) => ValidateName(dbContext, request)
.BindT(_ => SmartCollectionMustExist(dbContext, request, cancellationToken)); .MapT(_ => smartCollection);
private static Task<Validation<BaseError, SmartCollection>> SmartCollectionMustExist( private static Task<Option<SmartCollection>> SmartCollectionMustExist(
TvContext dbContext, TvContext dbContext,
UpdateSmartCollection updateCollection, UpdateSmartCollection updateCollection,
CancellationToken cancellationToken) => CancellationToken cancellationToken) =>
dbContext.SmartCollections dbContext.SmartCollections
.SelectOneAsync(c => c.Id, c => c.Id == updateCollection.Id, cancellationToken) .SelectOneAsync(c => c.Id, c => c.Id == updateCollection.Id, cancellationToken)
.Map(o => o.ToValidation<BaseError>("SmartCollection does not exist.")); .Map(identity);
private static async Task<Validation<BaseError, string>> ValidateName( private static async Task<Validation<BaseError, string>> ValidateName(
TvContext dbContext, TvContext dbContext,
@@ -571,7 +571,8 @@ public class CustomStreamSelectorTests
} }
[Test] [Test]
public async Task Should_Select_English_Audio_No_Subtitles_Day_Of_Week_Content_Condition_Fail() [SetCulture("en-US")]
public async Task Should_Select_English_Audio_No_Subtitles_Day_Of_Week_Content_Condition_Fail_SundayFirstCulture()
{ {
const string YAML = const string YAML =
""" """
@@ -608,7 +609,8 @@ public class CustomStreamSelectorTests
} }
[Test] [Test]
public async Task Should_Select_English_Audio_No_Subtitles_Day_Of_Week_Content_Condition_Match() [SetCulture("en-US")]
public async Task Should_Select_English_Audio_No_Subtitles_Day_Of_Week_Content_Condition_Match_SundayFirstCulture()
{ {
const string YAML = const string YAML =
""" """
@@ -651,7 +653,8 @@ public class CustomStreamSelectorTests
} }
[Test] [Test]
public async Task Should_Select_English_Audio_No_Subtitles_Day_Of_Week_Time_Of_Day_Content_Condition_Fail_Before() [SetCulture("en-US")]
public async Task Should_Select_English_Audio_No_Subtitles_Day_Of_Week_Time_Of_Day_Content_Condition_Fail_Before_SundayFirstCulture()
{ {
// saturday from 9pm-11pm // saturday from 9pm-11pm
const string YAML = const string YAML =
@@ -689,7 +692,8 @@ public class CustomStreamSelectorTests
} }
[Test] [Test]
public async Task Should_Select_English_Audio_No_Subtitles_Day_Of_Week_Time_Of_Day_Content_Condition_Fail_After() [SetCulture("en-US")]
public async Task Should_Select_English_Audio_No_Subtitles_Day_Of_Week_Time_Of_Day_Content_Condition_Fail_After_SundayFirstCulture()
{ {
// saturday from 9pm-11pm // saturday from 9pm-11pm
const string YAML = const string YAML =
@@ -727,7 +731,8 @@ public class CustomStreamSelectorTests
} }
[Test] [Test]
public async Task Should_Select_English_Audio_No_Subtitles_Day_Of_Week_Time_Of_Day_Content_Condition_Fail_Wrong_Day() [SetCulture("en-US")]
public async Task Should_Select_English_Audio_No_Subtitles_Day_Of_Week_Time_Of_Day_Content_Condition_Fail_Wrong_Day_SundayFirstCulture()
{ {
// saturday from 9pm-11pm // saturday from 9pm-11pm
const string YAML = const string YAML =
@@ -765,7 +770,8 @@ public class CustomStreamSelectorTests
} }
[Test] [Test]
public async Task Should_Select_English_Audio_No_Subtitles_Day_Of_Week_Time_Of_Day_Content_Condition_Match() [SetCulture("en-US")]
public async Task Should_Select_English_Audio_No_Subtitles_Day_Of_Week_Time_Of_Day_Content_Condition_Match_SundayFirstCulture()
{ {
// saturday from 9pm-11pm // saturday from 9pm-11pm
const string YAML = const string YAML =
@@ -810,7 +816,7 @@ public class CustomStreamSelectorTests
[Test] [Test]
[SetCulture("fr-FR")] [SetCulture("fr-FR")]
public async Task Should_Select_English_Audio_No_Subtitles_Day_Of_Week_Time_Of_Day_Content_Condition_Match_France() public async Task Should_Select_English_Audio_No_Subtitles_Day_Of_Week_Time_Of_Day_Content_Condition_Match_MondayFirstCulture()
{ {
// saturday from 9pm-11pm // saturday from 9pm-11pm
const string YAML = const string YAML =
@@ -853,6 +859,45 @@ public class CustomStreamSelectorTests
} }
} }
[Test]
[SetCulture("fr-FR")]
public async Task Should_Select_English_Audio_No_Subtitles_Day_Of_Week_Time_Of_Day_Content_Condition_Fail_Wrong_Day_MondayFirstCulture()
{
// In a Monday-first culture, saturday is day 5 and sunday is day 6.
const string YAML =
"""
---
items:
- audio_language: ["ja"]
subtitle_language: ["eng"]
content_condition: "day_of_week = 5 and (time_of_day_seconds >= 75600 and time_of_day_seconds < 82800)"
- audio_language: ["eng"]
disable_subtitles: true
""";
var fileSystem = new MockFileSystem();
fileSystem.Initialize()
.WithFile(TestFileName).Which(f => f.HasStringContent(YAML));
var streamSelector = new CustomStreamSelector(fileSystem, _logger);
var tz = TZConvert.GetTimeZoneInfo("America/Chicago");
var start = new DateTime(2026, 1, 11, 22, 0, 0, DateTimeKind.Unspecified); // sunday at 10:00pm
var dto = new DateTimeOffset(start, tz.GetUtcOffset(start));
StreamSelectorResult result = await streamSelector.SelectStreams(_channel, dto, _audioVersion, _subtitles);
result.AudioStream.IsSome.ShouldBeTrue();
foreach (MediaStream audioStream in result.AudioStream)
{
audioStream.Index.ShouldBe(1);
audioStream.Language.ShouldBe("eng");
}
result.Subtitle.IsSome.ShouldBeFalse();
}
[Test] [Test]
public async Task Should_Ignore_Blocked_Audio_Title() public async Task Should_Ignore_Blocked_Audio_Title()
{ {
@@ -0,0 +1,149 @@
using ErsatzTV.Application;
using ErsatzTV.Application.MediaCollections;
using ErsatzTV.Application.Search;
using ErsatzTV.Core;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Interfaces.Repositories;
using LanguageExt;
using ErsatzTV.Tests.Support;
using NSubstitute;
using NUnit.Framework;
using Shouldly;
using Unit = LanguageExt.Unit;
namespace ErsatzTV.Tests.Application.MediaCollections;
[TestFixture]
public class CollectionHandlerTests : MediaCollectionHandlerTestBase
{
[Test]
public async Task Update_Should_Return_NotFoundError_When_Collection_Missing()
{
var handler = new UpdateCollectionHandler(
Db.Factory,
Substitute.For<IMediaCollectionRepository>(),
Worker,
SearchTargets);
Either<BaseError, Unit> result =
await handler.Handle(new UpdateCollection(999, "Updated"), CancellationToken.None);
LeftOf(result).ShouldBeOfType<NotFoundError>();
}
[Test]
public async Task Delete_Should_Return_NotFoundError_When_Collection_Missing()
{
var handler = new DeleteCollectionHandler(Db.Factory, SearchTargets);
Either<BaseError, Unit> result =
await handler.Handle(new DeleteCollection(999), CancellationToken.None);
LeftOf(result).ShouldBeOfType<NotFoundError>();
}
[Test]
public async Task AddItems_Should_Return_NotFoundError_When_Collection_Missing()
{
IMovieRepository movieRepository = Substitute.For<IMovieRepository>();
movieRepository.AllMoviesExist(Arg.Any<List<int>>()).Returns(true);
ITelevisionRepository televisionRepository = Substitute.For<ITelevisionRepository>();
televisionRepository.AllShowsExist(Arg.Any<List<int>>()).Returns(true);
televisionRepository.AllSeasonsExist(Arg.Any<List<int>>()).Returns(true);
televisionRepository.AllEpisodesExist(Arg.Any<List<int>>()).Returns(true);
var handler = new AddItemsToCollectionHandler(
Db.Factory,
Substitute.For<IMediaCollectionRepository>(),
movieRepository,
televisionRepository,
Worker,
System.Threading.Channels.Channel.CreateUnbounded<ISearchIndexBackgroundServiceRequest>().Writer);
Either<BaseError, Unit> result =
await handler.Handle(MakeAddItems(collectionId: 999, movieIds: [1]), CancellationToken.None);
LeftOf(result).ShouldBeOfType<NotFoundError>();
}
[Test]
public async Task AddItems_Should_Return_ValidationError_When_Generic_MediaItem_Missing()
{
await SeedCollection(1);
IMovieRepository movieRepository = Substitute.For<IMovieRepository>();
movieRepository.AllMoviesExist(Arg.Any<List<int>>()).Returns(true);
ITelevisionRepository televisionRepository = Substitute.For<ITelevisionRepository>();
televisionRepository.AllShowsExist(Arg.Any<List<int>>()).Returns(true);
televisionRepository.AllSeasonsExist(Arg.Any<List<int>>()).Returns(true);
televisionRepository.AllEpisodesExist(Arg.Any<List<int>>()).Returns(true);
var handler = new AddItemsToCollectionHandler(
Db.Factory,
Substitute.For<IMediaCollectionRepository>(),
movieRepository,
televisionRepository,
Worker,
System.Threading.Channels.Channel.CreateUnbounded<ISearchIndexBackgroundServiceRequest>().Writer);
Either<BaseError, Unit> result =
await handler.Handle(
new AddItemsToCollection(1, [], [], [], [], [], [], [], [], [], [999]),
CancellationToken.None);
BaseError error = LeftOf(result);
error.ShouldNotBeOfType<NotFoundError>();
error.Value.ShouldContain("Media item does not exist");
}
[Test]
public async Task RemoveItems_Should_Return_NotFoundError_When_Collection_Missing()
{
var handler = new RemoveItemsFromCollectionHandler(
Db.Factory,
Substitute.For<IMediaCollectionRepository>(),
Worker,
System.Threading.Channels.Channel.CreateUnbounded<ISearchIndexBackgroundServiceRequest>().Writer);
Either<BaseError, Unit> result =
await handler.Handle(
new RemoveItemsFromCollection(999) { MediaItemIds = [1] },
CancellationToken.None);
LeftOf(result).ShouldBeOfType<NotFoundError>();
}
[Test]
public async Task RemoveItems_Should_Return_NotFoundError_When_Association_Missing()
{
await SeedCollection(1);
var handler = new RemoveItemsFromCollectionHandler(
Db.Factory,
Substitute.For<IMediaCollectionRepository>(),
Worker,
System.Threading.Channels.Channel.CreateUnbounded<ISearchIndexBackgroundServiceRequest>().Writer);
Either<BaseError, Unit> result =
await handler.Handle(
new RemoveItemsFromCollection(1) { MediaItemIds = [999] },
CancellationToken.None);
LeftOf(result).ShouldBeOfType<NotFoundError>();
}
private static AddItemsToCollection MakeAddItems(int collectionId, List<int>? movieIds = null) =>
new(
collectionId,
movieIds ?? [],
[],
[],
[],
[],
[],
[],
[],
[],
[]);
private static BaseError LeftOf<TR>(Either<BaseError, TR> either) =>
either.Match(Left: e => e, Right: _ => throw new AssertionException("Expected a Left result"));
}
@@ -0,0 +1,50 @@
using ErsatzTV.Application.MediaCollections;
using ErsatzTV.Core;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Search;
using LanguageExt;
using ErsatzTV.Tests.Support;
using NSubstitute;
using NUnit.Framework;
using Shouldly;
using Unit = LanguageExt.Unit;
namespace ErsatzTV.Tests.Application.MediaCollections;
[TestFixture]
public class SmartCollectionHandlerTests : MediaCollectionHandlerTestBase
{
[Test]
public async Task Update_Should_Return_NotFoundError_When_SmartCollection_Missing()
{
var handler = new UpdateSmartCollectionHandler(
Db.Factory,
Substitute.For<IMediaCollectionRepository>(),
Worker,
SearchTargets,
Substitute.For<ISmartCollectionCache>());
Either<BaseError, UpdateSmartCollectionResult> result =
await handler.Handle(new UpdateSmartCollection(999, "Updated", "tag:updated"), CancellationToken.None);
LeftOf(result).ShouldBeOfType<NotFoundError>();
}
[Test]
public async Task Delete_Should_Return_NotFoundError_When_SmartCollection_Missing()
{
var handler = new DeleteSmartCollectionHandler(
Db.Factory,
SearchTargets,
Substitute.For<ISmartCollectionCache>());
Either<BaseError, Unit> result =
await handler.Handle(new DeleteSmartCollection(999), CancellationToken.None);
LeftOf(result).ShouldBeOfType<NotFoundError>();
}
private static BaseError LeftOf<TR>(Either<BaseError, TR> either) =>
either.Match(Left: e => e, Right: _ => throw new AssertionException("Expected a Left result"));
}
@@ -0,0 +1,56 @@
using System.Reflection;
using ErsatzTV.Controllers.Api;
using ErsatzTV.Filters;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Routing;
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Tests.Controllers;
[TestFixture]
public class CollectionControllerSecurityTests
{
[TestCase(typeof(CollectionController))]
[TestCase(typeof(SmartCollectionController))]
public void Controller_Should_Apply_ApiKeyAuthorizationFilter(Type controllerType)
{
ServiceFilterAttribute? filter = controllerType
.GetCustomAttributes<ServiceFilterAttribute>(inherit: true)
.SingleOrDefault(a => a.ServiceType == typeof(ApiKeyAuthorizationFilter));
filter.ShouldNotBeNull($"{controllerType.Name} must carry ApiKeyAuthorizationFilter at the class level");
}
[TestCase(typeof(CollectionController))]
[TestCase(typeof(SmartCollectionController))]
public void Every_Mutating_Action_Should_Be_Protected(Type controllerType)
{
MethodInfo[] actions = controllerType
.GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly);
bool controllerHasFilter = controllerType
.GetCustomAttributes<ServiceFilterAttribute>(inherit: true)
.Any(a => a.ServiceType == typeof(ApiKeyAuthorizationFilter));
foreach (MethodInfo action in actions)
{
bool isMutating = action
.GetCustomAttributes<HttpMethodAttribute>(inherit: true)
.SelectMany(a => a.HttpMethods)
.Any(m => m is "POST" or "PUT" or "PATCH" or "DELETE");
if (!isMutating)
{
continue;
}
bool actionHasFilter = action
.GetCustomAttributes<ServiceFilterAttribute>(inherit: true)
.Any(a => a.ServiceType == typeof(ApiKeyAuthorizationFilter));
(controllerHasFilter || actionHasFilter)
.ShouldBeTrue($"Mutating action {controllerType.Name}.{action.Name} is not protected");
}
}
}
@@ -0,0 +1,223 @@
using ErsatzTV.Application.MediaCollections;
using ErsatzTV.Controllers.Api;
using ErsatzTV.Controllers.Api.Requests;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Errors;
using LanguageExt;
using MediatR;
using Microsoft.AspNetCore.Mvc;
using NSubstitute;
using NUnit.Framework;
using Shouldly;
using static LanguageExt.Prelude;
using Unit = LanguageExt.Unit;
namespace ErsatzTV.Tests.Controllers;
[TestFixture]
public class CollectionControllerTests
{
private CollectionController _controller = null!;
private IMediator _mediator = null!;
[SetUp]
public void SetUp()
{
_mediator = Substitute.For<IMediator>();
_controller = new CollectionController(_mediator);
}
[Test]
public async Task Create_Should_Return_201_With_Location_And_Body()
{
MediaCollectionViewModel vm = MakeVm(5, "Movies");
_mediator.Send(Arg.Any<CreateCollection>(), Arg.Any<CancellationToken>())
.Returns(Right<BaseError, MediaCollectionViewModel>(vm));
IActionResult result = await _controller.Create(new CreateCollectionRequest("Movies"), CancellationToken.None);
var created = result.ShouldBeOfType<CreatedResult>();
created.StatusCode.ShouldBe(201);
created.Location.ShouldBe("/api/collections/5");
created.Value.ShouldBe(vm);
}
[Test]
public async Task Create_Should_Return_422_On_Validation_Error()
{
_mediator.Send(Arg.Any<CreateCollection>(), Arg.Any<CancellationToken>())
.Returns(Left<BaseError, MediaCollectionViewModel>(BaseError.New("bad")));
IActionResult result = await _controller.Create(new CreateCollectionRequest(string.Empty), CancellationToken.None);
result.ShouldBeOfType<UnprocessableEntityObjectResult>();
}
[Test]
public async Task Create_Should_Map_Request_To_Command()
{
_mediator.Send(Arg.Any<CreateCollection>(), Arg.Any<CancellationToken>())
.Returns(Right<BaseError, MediaCollectionViewModel>(MakeVm(5, "Movies")));
await _controller.Create(new CreateCollectionRequest("Movies"), CancellationToken.None);
await _mediator.Received(1).Send(
Arg.Is<CreateCollection>(c => c.Name == "Movies"),
Arg.Any<CancellationToken>());
}
[Test]
public async Task Update_Should_Return_200_And_Map_Route_Id()
{
_mediator.Send(Arg.Any<UpdateCollection>(), Arg.Any<CancellationToken>())
.Returns(Right<BaseError, Unit>(Unit.Default));
MediaCollectionViewModel vm = MakeVm(7, "Updated");
_mediator.Send(Arg.Any<GetCollectionById>(), Arg.Any<CancellationToken>())
.Returns(Option<MediaCollectionViewModel>.Some(vm));
IActionResult result = await _controller.Update(
7,
new UpdateCollectionRequest("Updated", true),
CancellationToken.None);
result.ShouldBeOfType<OkObjectResult>().Value.ShouldBe(vm);
await _mediator.Received(1).Send(
Arg.Is<UpdateCollection>(c =>
c.CollectionId == 7 &&
c.Name == "Updated" &&
c.UseCustomPlaybackOrder.Match(Some: v => v, None: () => false)),
Arg.Any<CancellationToken>());
}
[Test]
public async Task Update_Should_Return_404_For_NotFoundError()
{
_mediator.Send(Arg.Any<UpdateCollection>(), Arg.Any<CancellationToken>())
.Returns(Left<BaseError, Unit>(new NotFoundError("missing")));
IActionResult result = await _controller.Update(
99,
new UpdateCollectionRequest("Missing", false),
CancellationToken.None);
result.ShouldBeOfType<NotFoundObjectResult>();
}
[Test]
public async Task Delete_Should_Return_204_On_Success()
{
_mediator.Send(Arg.Any<DeleteCollection>(), Arg.Any<CancellationToken>())
.Returns(Right<BaseError, Unit>(Unit.Default));
IActionResult result = await _controller.Delete(3, CancellationToken.None);
result.ShouldBeOfType<NoContentResult>();
}
[Test]
public async Task Delete_Should_Return_404_For_NotFoundError()
{
_mediator.Send(Arg.Any<DeleteCollection>(), Arg.Any<CancellationToken>())
.Returns(Left<BaseError, Unit>(new NotFoundError("missing")));
IActionResult result = await _controller.Delete(99, CancellationToken.None);
result.ShouldBeOfType<NotFoundObjectResult>();
}
[Test]
public async Task AddItems_Should_Return_204_And_Map_Route_Id()
{
_mediator.Send(Arg.Any<AddItemsToCollection>(), Arg.Any<CancellationToken>())
.Returns(Right<BaseError, Unit>(Unit.Default));
IActionResult result = await _controller.AddItems(
3,
new AddItemsToCollectionRequest([10], null, null, null, null, null, null, null, null, null),
CancellationToken.None);
result.ShouldBeOfType<NoContentResult>();
await _mediator.Received(1).Send(
Arg.Is<AddItemsToCollection>(c => c.CollectionId == 3 && c.MovieIds.SequenceEqual(new[] { 10 })),
Arg.Any<CancellationToken>());
}
[Test]
public async Task AddItems_Should_Return_422_On_Validation_Error()
{
_mediator.Send(Arg.Any<AddItemsToCollection>(), Arg.Any<CancellationToken>())
.Returns(Left<BaseError, Unit>(BaseError.New("bad")));
IActionResult result = await _controller.AddItems(
3,
new AddItemsToCollectionRequest(null, null, null, null, null, null, null, null, null, [999]),
CancellationToken.None);
result.ShouldBeOfType<UnprocessableEntityObjectResult>();
}
[Test]
public async Task RemoveItem_Should_Return_204_And_Map_Route_Ids()
{
_mediator.Send(Arg.Any<RemoveItemsFromCollection>(), Arg.Any<CancellationToken>())
.Returns(Right<BaseError, Unit>(Unit.Default));
IActionResult result = await _controller.RemoveItem(3, 10, CancellationToken.None);
result.ShouldBeOfType<NoContentResult>();
await _mediator.Received(1).Send(
Arg.Is<RemoveItemsFromCollection>(c =>
c.MediaCollectionId == 3 && c.MediaItemIds.SequenceEqual(new[] { 10 })),
Arg.Any<CancellationToken>());
}
[Test]
public async Task RemoveItem_Should_Return_404_For_NotFoundError()
{
_mediator.Send(Arg.Any<RemoveItemsFromCollection>(), Arg.Any<CancellationToken>())
.Returns(Left<BaseError, Unit>(new NotFoundError("missing")));
IActionResult result = await _controller.RemoveItem(3, 10, CancellationToken.None);
result.ShouldBeOfType<NotFoundObjectResult>();
}
[Test]
public async Task GetAll_Should_Return_Collections()
{
List<MediaCollectionViewModel> vms = [MakeVm(1, "Movies"), MakeVm(2, "Shows")];
_mediator.Send(Arg.Any<GetAllCollections>(), Arg.Any<CancellationToken>())
.Returns(vms);
List<MediaCollectionViewModel> result = await _controller.GetAll(CancellationToken.None);
result.ShouldBe(vms);
}
[Test]
public async Task GetById_Should_Return_200_For_Some()
{
MediaCollectionViewModel vm = MakeVm(4, "Movies");
_mediator.Send(Arg.Any<GetCollectionById>(), Arg.Any<CancellationToken>())
.Returns(Option<MediaCollectionViewModel>.Some(vm));
IActionResult result = await _controller.GetById(4, CancellationToken.None);
result.ShouldBeOfType<OkObjectResult>().Value.ShouldBe(vm);
}
[Test]
public async Task GetById_Should_Return_404_For_None()
{
_mediator.Send(Arg.Any<GetCollectionById>(), Arg.Any<CancellationToken>())
.Returns(Option<MediaCollectionViewModel>.None);
IActionResult result = await _controller.GetById(4, CancellationToken.None);
result.ShouldBeOfType<NotFoundResult>();
}
private static MediaCollectionViewModel MakeVm(int id, string name) =>
new(CollectionType.Collection, id, name, false, MediaItemState.Normal);
}
@@ -0,0 +1,203 @@
using System.Reflection;
using ErsatzTV.Application.MediaCollections;
using ErsatzTV.Controllers.Api;
using ErsatzTV.Controllers.Api.Requests;
using ErsatzTV.Core;
using ErsatzTV.Core.Api.SmartCollections;
using ErsatzTV.Core.Errors;
using ErsatzTV.Filters;
using LanguageExt;
using MediatR;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Routing;
using NSubstitute;
using NUnit.Framework;
using Shouldly;
using static LanguageExt.Prelude;
using Unit = LanguageExt.Unit;
namespace ErsatzTV.Tests.Controllers;
[TestFixture]
public class SmartCollectionControllerTests
{
private SmartCollectionController _controller = null!;
private IMediator _mediator = null!;
[SetUp]
public void SetUp()
{
_mediator = Substitute.For<IMediator>();
_controller = new SmartCollectionController(_mediator);
}
[Test]
public void Controller_Should_Expose_Idiomatic_Rest_Routes()
{
ShouldHaveActionRoute(nameof(SmartCollectionController.GetAll), "GET", "/api/smart-collections");
ShouldHaveActionRoute(nameof(SmartCollectionController.GetById), "GET", "/api/smart-collections/{id:int}");
ShouldHaveActionRoute(nameof(SmartCollectionController.Create), "POST", "/api/smart-collections");
ShouldHaveActionRoute(nameof(SmartCollectionController.Update), "PUT", "/api/smart-collections/{id:int}");
ShouldHaveActionRoute(nameof(SmartCollectionController.Delete), "DELETE", "/api/smart-collections/{id:int}");
}
[Test]
public void Controller_Should_Apply_ApiKeyAuthorizationFilter()
{
ServiceFilterAttribute? filter = typeof(SmartCollectionController)
.GetCustomAttributes<ServiceFilterAttribute>(inherit: true)
.SingleOrDefault(a => a.ServiceType == typeof(ApiKeyAuthorizationFilter));
filter.ShouldNotBeNull("SmartCollectionController must carry ApiKeyAuthorizationFilter at the class level");
}
[Test]
public async Task Create_Should_Return_201_With_Location_And_Body()
{
var vm = new SmartCollectionViewModel(7, "Kids", "tag:family");
_mediator.Send(Arg.Any<CreateSmartCollection>(), Arg.Any<CancellationToken>())
.Returns(Right<BaseError, SmartCollectionViewModel>(vm));
IActionResult result = await _controller.Create(
new CreateSmartCollectionRequest("Kids", "tag:family"),
CancellationToken.None);
var created = result.ShouldBeOfType<CreatedResult>();
created.StatusCode.ShouldBe(201);
created.Location.ShouldBe("/api/smart-collections/7");
created.Value.ShouldBe(vm);
}
[Test]
public async Task Create_Should_Return_422_On_Validation_Error()
{
_mediator.Send(Arg.Any<CreateSmartCollection>(), Arg.Any<CancellationToken>())
.Returns(Left<BaseError, SmartCollectionViewModel>(BaseError.New("bad")));
IActionResult result = await _controller.Create(
new CreateSmartCollectionRequest(string.Empty, string.Empty),
CancellationToken.None);
result.ShouldBeOfType<UnprocessableEntityObjectResult>();
}
[Test]
public async Task Create_Should_Map_Request_To_Command()
{
_mediator.Send(Arg.Any<CreateSmartCollection>(), Arg.Any<CancellationToken>())
.Returns(Right<BaseError, SmartCollectionViewModel>(
new SmartCollectionViewModel(7, "Kids", "tag:family")));
await _controller.Create(new CreateSmartCollectionRequest("Kids", "tag:family"), CancellationToken.None);
await _mediator.Received(1).Send(
Arg.Is<CreateSmartCollection>(c => c.Name == "Kids" && c.Query == "tag:family"),
Arg.Any<CancellationToken>());
}
[Test]
public async Task Update_Should_Return_200_And_Map_Route_Id()
{
_mediator.Send(Arg.Any<UpdateSmartCollection>(), Arg.Any<CancellationToken>())
.Returns(Right<BaseError, UpdateSmartCollectionResult>(new UpdateSmartCollectionResult(8)));
var vm = new SmartCollectionViewModel(8, "Updated", "tag:updated");
_mediator.Send(Arg.Any<GetSmartCollectionById>(), Arg.Any<CancellationToken>())
.Returns(Option<SmartCollectionViewModel>.Some(vm));
IActionResult result = await _controller.Update(
8,
new UpdateSmartCollectionRequest("Updated", "tag:updated"),
CancellationToken.None);
result.ShouldBeOfType<OkObjectResult>().Value.ShouldBe(vm);
await _mediator.Received(1).Send(
Arg.Is<UpdateSmartCollection>(c =>
c.Id == 8 && c.Name == "Updated" && c.Query == "tag:updated"),
Arg.Any<CancellationToken>());
}
[Test]
public async Task Update_Should_Return_404_For_NotFoundError()
{
_mediator.Send(Arg.Any<UpdateSmartCollection>(), Arg.Any<CancellationToken>())
.Returns(Left<BaseError, UpdateSmartCollectionResult>(new NotFoundError("missing")));
IActionResult result = await _controller.Update(
99,
new UpdateSmartCollectionRequest("Missing", "tag:missing"),
CancellationToken.None);
result.ShouldBeOfType<NotFoundObjectResult>();
}
[Test]
public async Task Delete_Should_Return_204_On_Success()
{
_mediator.Send(Arg.Any<DeleteSmartCollection>(), Arg.Any<CancellationToken>())
.Returns(Right<BaseError, Unit>(Unit.Default));
IActionResult result = await _controller.Delete(9, CancellationToken.None);
result.ShouldBeOfType<NoContentResult>();
}
[Test]
public async Task Delete_Should_Return_404_For_NotFoundError()
{
_mediator.Send(Arg.Any<DeleteSmartCollection>(), Arg.Any<CancellationToken>())
.Returns(Left<BaseError, Unit>(new NotFoundError("missing")));
IActionResult result = await _controller.Delete(9, CancellationToken.None);
result.ShouldBeOfType<NotFoundObjectResult>();
}
[Test]
public async Task GetAll_Should_Return_SmartCollections()
{
List<SmartCollectionResponseModel> vms =
[
new SmartCollectionResponseModel(1, "Kids", "tag:kids"),
new SmartCollectionResponseModel(2, "News", "tag:news")
];
_mediator.Send(Arg.Any<GetAllSmartCollectionsForApi>(), Arg.Any<CancellationToken>())
.Returns(vms);
List<SmartCollectionResponseModel> result = await _controller.GetAll(CancellationToken.None);
result.ShouldBe(vms);
}
[Test]
public async Task GetById_Should_Return_200_For_Some()
{
var vm = new SmartCollectionViewModel(4, "Kids", "tag:kids");
_mediator.Send(Arg.Any<GetSmartCollectionById>(), Arg.Any<CancellationToken>())
.Returns(Option<SmartCollectionViewModel>.Some(vm));
IActionResult result = await _controller.GetById(4, CancellationToken.None);
result.ShouldBeOfType<OkObjectResult>().Value.ShouldBe(vm);
}
[Test]
public async Task GetById_Should_Return_404_For_None()
{
_mediator.Send(Arg.Any<GetSmartCollectionById>(), Arg.Any<CancellationToken>())
.Returns(Option<SmartCollectionViewModel>.None);
IActionResult result = await _controller.GetById(4, CancellationToken.None);
result.ShouldBeOfType<NotFoundResult>();
}
private static void ShouldHaveActionRoute(string actionName, string httpMethod, string route)
{
MethodInfo action = typeof(SmartCollectionController).GetMethod(actionName)
?? throw new AssertionException($"Missing action {actionName}");
HttpMethodAttribute attribute = action.GetCustomAttributes<HttpMethodAttribute>(inherit: true).Single();
attribute.HttpMethods.ShouldContain(httpMethod);
attribute.Template.ShouldBe(route);
}
}
@@ -0,0 +1,131 @@
using ErsatzTV.Application;
using ErsatzTV.Application.MediaCollections;
using ErsatzTV.Application.Search;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Tests.Support;
using LanguageExt;
using Microsoft.EntityFrameworkCore;
using NSubstitute;
using NUnit.Framework;
using Shouldly;
using Unit = LanguageExt.Unit;
namespace ErsatzTV.Tests.Integration;
/// <summary>
/// End-to-end create -> read -> add item -> remove item -> delete against the in-memory SQLite harness,
/// exercising the real EF Core handlers and collection item persistence.
/// </summary>
[TestFixture]
public class CollectionLifecycleIntegrationTests : MediaCollectionHandlerTestBase
{
[Test]
public async Task Create_Read_AddItem_RemoveItem_Delete()
{
int movieId = await SeedMovie();
IMediaCollectionRepository mediaCollectionRepository = Substitute.For<IMediaCollectionRepository>();
mediaCollectionRepository.PlayoutIdsUsingCollection(Arg.Any<int>()).Returns([]);
IMovieRepository movieRepository = Substitute.For<IMovieRepository>();
movieRepository.AllMoviesExist(Arg.Any<List<int>>()).Returns(true);
ITelevisionRepository televisionRepository = Substitute.For<ITelevisionRepository>();
televisionRepository.AllShowsExist(Arg.Any<List<int>>()).Returns(true);
televisionRepository.AllSeasonsExist(Arg.Any<List<int>>()).Returns(true);
televisionRepository.AllEpisodesExist(Arg.Any<List<int>>()).Returns(true);
var createHandler = new CreateCollectionHandler(Db.Factory, SearchTargets);
Either<BaseError, MediaCollectionViewModel> created =
await createHandler.Handle(new CreateCollection("Integration"), CancellationToken.None);
int collectionId = created.Match(Left: _ => throw new AssertionException("create failed"), Right: r => r.Id);
collectionId.ShouldBeGreaterThan(0);
var getHandler = new GetCollectionByIdHandler(Db.Factory);
Option<MediaCollectionViewModel> afterCreate =
await getHandler.Handle(new GetCollectionById(collectionId), CancellationToken.None);
afterCreate.IsSome.ShouldBeTrue();
afterCreate.Match(
Some: vm => vm.Name.ShouldBe("Integration"),
None: () => throw new AssertionException("expected collection to exist"));
var addHandler = new AddItemsToCollectionHandler(
Db.Factory,
mediaCollectionRepository,
movieRepository,
televisionRepository,
Worker,
System.Threading.Channels.Channel.CreateUnbounded<ISearchIndexBackgroundServiceRequest>().Writer);
Either<BaseError, Unit> added =
await addHandler.Handle(MakeAddItems(collectionId, movieId), CancellationToken.None);
added.IsRight.ShouldBeTrue();
await using (TvContext context = Db.CreateContext())
{
bool itemExists = await context.CollectionItems
.AnyAsync(ci => ci.CollectionId == collectionId && ci.MediaItemId == movieId);
itemExists.ShouldBeTrue();
}
var removeHandler = new RemoveItemsFromCollectionHandler(
Db.Factory,
mediaCollectionRepository,
Worker,
System.Threading.Channels.Channel.CreateUnbounded<ISearchIndexBackgroundServiceRequest>().Writer);
Either<BaseError, Unit> removed =
await removeHandler.Handle(
new RemoveItemsFromCollection(collectionId) { MediaItemIds = [movieId] },
CancellationToken.None);
removed.IsRight.ShouldBeTrue();
await using (TvContext context = Db.CreateContext())
{
bool itemExists = await context.CollectionItems
.AnyAsync(ci => ci.CollectionId == collectionId && ci.MediaItemId == movieId);
itemExists.ShouldBeFalse();
}
var deleteHandler = new DeleteCollectionHandler(Db.Factory, SearchTargets);
Either<BaseError, Unit> deleted =
await deleteHandler.Handle(new DeleteCollection(collectionId), CancellationToken.None);
deleted.IsRight.ShouldBeTrue();
Option<MediaCollectionViewModel> afterDelete =
await getHandler.Handle(new GetCollectionById(collectionId), CancellationToken.None);
afterDelete.IsNone.ShouldBeTrue();
}
private async Task<int> SeedMovie()
{
await using TvContext context = Db.CreateContext();
var mediaSource = new LocalMediaSource();
var library = new LocalLibrary
{
Name = "Movies",
MediaKind = LibraryMediaKind.Movies,
MediaSource = mediaSource,
Paths = []
};
var libraryPath = new LibraryPath
{
Path = "/media/movies",
Library = library,
LibraryFolders = [],
MediaItems = []
};
var movie = new Movie
{
LibraryPath = libraryPath,
MovieMetadata = [],
MediaVersions = [],
Collections = []
};
context.Movies.Add(movie);
await context.SaveChangesAsync();
return movie.Id;
}
private static AddItemsToCollection MakeAddItems(int collectionId, int movieId) =>
new(collectionId, [movieId], [], [], [], [], [], [], [], [], []);
}
@@ -0,0 +1,51 @@
using System.Threading.Channels;
using ErsatzTV.Application;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Infrastructure.Data;
using NSubstitute;
using NUnit.Framework;
namespace ErsatzTV.Tests.Support;
public abstract class MediaCollectionHandlerTestBase
{
protected InMemoryTvContext Db = null!;
protected ChannelWriter<IBackgroundServiceRequest> Worker = null!;
protected ISearchTargets SearchTargets = null!;
[SetUp]
public async Task BaseSetUp()
{
Db = await InMemoryTvContext.CreateAsync();
Worker = System.Threading.Channels.Channel.CreateUnbounded<IBackgroundServiceRequest>().Writer;
SearchTargets = Substitute.For<ISearchTargets>();
}
[TearDown]
public async Task BaseTearDown() => await Db.DisposeAsync();
protected async Task SeedCollection(int id, string name = "Collection")
{
await using TvContext context = Db.CreateContext();
context.Collections.Add(new Collection
{
Id = id,
Name = name,
MediaItems = []
});
await context.SaveChangesAsync();
}
protected async Task SeedSmartCollection(int id, string name = "Smart", string query = "tag:family")
{
await using TvContext context = Db.CreateContext();
context.SmartCollections.Add(new SmartCollection
{
Id = id,
Name = name,
Query = query
});
await context.SaveChangesAsync();
}
}
@@ -0,0 +1,105 @@
using System.ComponentModel.DataAnnotations;
using ErsatzTV.Application.MediaCollections;
using ErsatzTV.Controllers.Api.Requests;
using ErsatzTV.Core;
using ErsatzTV.Extensions;
using ErsatzTV.Filters;
using MediatR;
using Microsoft.AspNetCore.Mvc;
namespace ErsatzTV.Controllers.Api;
[ApiController]
[ServiceFilter(typeof(ApiKeyAuthorizationFilter))]
public class CollectionController(IMediator mediator) : ControllerBase
{
[HttpGet("/api/collections")]
[Tags("Collections")]
[EndpointSummary("Get all collections")]
[EndpointGroupName("general")]
public async Task<List<MediaCollectionViewModel>> GetAll(CancellationToken cancellationToken) =>
await mediator.Send(new GetAllCollections(), cancellationToken);
[HttpGet("/api/collections/{id:int}", Name = "GetCollectionById")]
[Tags("Collections")]
[EndpointSummary("Get a collection by id")]
[EndpointGroupName("general")]
public async Task<IActionResult> GetById(int id, CancellationToken cancellationToken)
{
Option<MediaCollectionViewModel> result = await mediator.Send(new GetCollectionById(id), cancellationToken);
return result.ToGetResult();
}
[HttpPost("/api/collections")]
[Tags("Collections")]
[EndpointSummary("Create a collection")]
[EndpointGroupName("general")]
public async Task<IActionResult> Create(
[Required] [FromBody] CreateCollectionRequest request,
CancellationToken cancellationToken)
{
Either<BaseError, MediaCollectionViewModel> result =
await mediator.Send(request.ToCommand(), cancellationToken);
return result.ToCreatedResult(vm => $"/api/collections/{vm.Id}", vm => vm);
}
[HttpPut("/api/collections/{id:int}")]
[Tags("Collections")]
[EndpointSummary("Update a collection")]
[EndpointGroupName("general")]
public async Task<IActionResult> Update(
int id,
[Required] [FromBody] UpdateCollectionRequest request,
CancellationToken cancellationToken)
{
Either<BaseError, Unit> result = await mediator.Send(request.ToCommand(id), cancellationToken);
return await result.Match(
Left: error => Task.FromResult(error.ToErrorResult()),
Right: async _ =>
{
Option<MediaCollectionViewModel> collection =
await mediator.Send(new GetCollectionById(id), cancellationToken);
return collection.Match(
Some: vm => (IActionResult)new OkObjectResult(vm),
None: () => new NotFoundResult());
});
}
[HttpDelete("/api/collections/{id:int}")]
[Tags("Collections")]
[EndpointSummary("Delete a collection")]
[EndpointGroupName("general")]
public async Task<IActionResult> Delete(int id, CancellationToken cancellationToken)
{
Either<BaseError, Unit> result = await mediator.Send(new DeleteCollection(id), cancellationToken);
return result.ToDeletedResult();
}
[HttpPost("/api/collections/{id:int}/items")]
[Tags("Collections")]
[EndpointSummary("Add items to a collection")]
[EndpointGroupName("general")]
public async Task<IActionResult> AddItems(
int id,
[Required] [FromBody] AddItemsToCollectionRequest request,
CancellationToken cancellationToken)
{
Either<BaseError, Unit> result = await mediator.Send(request.ToCommand(id), cancellationToken);
return result.ToDeletedResult();
}
[HttpDelete("/api/collections/{id:int}/items/{mediaItemId:int}")]
[Tags("Collections")]
[EndpointSummary("Remove an item from a collection")]
[EndpointGroupName("general")]
public async Task<IActionResult> RemoveItem(int id, int mediaItemId, CancellationToken cancellationToken)
{
Either<BaseError, Unit> result = await mediator.Send(
new RemoveItemsFromCollection(id)
{
MediaItemIds = [mediaItemId]
},
cancellationToken);
return result.ToDeletedResult();
}
}
@@ -0,0 +1,30 @@
using ErsatzTV.Application.MediaCollections;
namespace ErsatzTV.Controllers.Api.Requests;
public record AddItemsToCollectionRequest(
List<int> MovieIds,
List<int> ShowIds,
List<int> SeasonIds,
List<int> EpisodeIds,
List<int> ArtistIds,
List<int> MusicVideoIds,
List<int> OtherVideoIds,
List<int> SongIds,
List<int> ImageIds,
List<int> RemoteStreamIds)
{
public AddItemsToCollection ToCommand(int collectionId) =>
new(
collectionId,
MovieIds ?? [],
ShowIds ?? [],
SeasonIds ?? [],
EpisodeIds ?? [],
ArtistIds ?? [],
MusicVideoIds ?? [],
OtherVideoIds ?? [],
SongIds ?? [],
ImageIds ?? [],
RemoteStreamIds ?? []);
}
@@ -0,0 +1,8 @@
using ErsatzTV.Application.MediaCollections;
namespace ErsatzTV.Controllers.Api.Requests;
public record CreateCollectionRequest(string Name)
{
public CreateCollection ToCommand() => new(Name);
}
@@ -0,0 +1,8 @@
using ErsatzTV.Application.MediaCollections;
namespace ErsatzTV.Controllers.Api.Requests;
public record CreateSmartCollectionRequest(string Name, string Query)
{
public CreateSmartCollection ToCommand() => new(Query, Name);
}
@@ -0,0 +1,12 @@
using ErsatzTV.Application.MediaCollections;
namespace ErsatzTV.Controllers.Api.Requests;
public record UpdateCollectionRequest(string Name, bool? UseCustomPlaybackOrder)
{
public UpdateCollection ToCommand(int id) =>
new(id, Name)
{
UseCustomPlaybackOrder = Optional(UseCustomPlaybackOrder)
};
}
@@ -0,0 +1,8 @@
using ErsatzTV.Application.MediaCollections;
namespace ErsatzTV.Controllers.Api.Requests;
public record UpdateSmartCollectionRequest(string Name, string Query)
{
public UpdateSmartCollection ToCommand(int id) => new(id, Name, Query);
}
@@ -1,43 +1,80 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using ErsatzTV.Application.MediaCollections; using ErsatzTV.Application.MediaCollections;
using ErsatzTV.Controllers.Api.Requests;
using ErsatzTV.Core; using ErsatzTV.Core;
using ErsatzTV.Core.Api.SmartCollections; using ErsatzTV.Core.Api.SmartCollections;
using ErsatzTV.Extensions;
using ErsatzTV.Filters;
using MediatR; using MediatR;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
namespace ErsatzTV.Controllers.Api; namespace ErsatzTV.Controllers.Api;
[ApiController] [ApiController]
[EndpointGroupName("general")] [ServiceFilter(typeof(ApiKeyAuthorizationFilter))]
public class SmartCollectionController(IMediator mediator) : ControllerBase public class SmartCollectionController(IMediator mediator) : ControllerBase
{ {
[HttpGet("/api/collections/smart", Name="GetSmartCollections")] [HttpGet("/api/smart-collections")]
public async Task<List<SmartCollectionResponseModel>> GetAll() => [Tags("Smart Collections")]
await mediator.Send(new GetAllSmartCollectionsForApi()); [EndpointSummary("Get all smart collections")]
[EndpointGroupName("general")]
public async Task<List<SmartCollectionResponseModel>> GetAll(CancellationToken cancellationToken) =>
await mediator.Send(new GetAllSmartCollectionsForApi(), cancellationToken);
[HttpPost("/api/collections/smart/new", Name = "CreateSmartCollection")] [HttpGet("/api/smart-collections/{id:int}", Name = "GetSmartCollectionById")]
public async Task<IActionResult> AddOne( [Tags("Smart Collections")]
[Required] [FromBody] [EndpointSummary("Get a smart collection by id")]
CreateSmartCollection request) [EndpointGroupName("general")]
public async Task<IActionResult> GetById(int id, CancellationToken cancellationToken)
{ {
Either<BaseError, CreateSmartCollectionResult> result = Option<SmartCollectionViewModel> result =
await mediator.Send(request).MapT(r => new CreateSmartCollectionResult(r.Id)); await mediator.Send(new GetSmartCollectionById(id), cancellationToken);
return result.Match<IActionResult>(Ok, error => Problem(error.ToString())); return result.ToGetResult();
} }
[HttpPut("/api/collections/smart/update", Name="UpdateSmartCollection")] [HttpPost("/api/smart-collections")]
public async Task<IActionResult> UpdateOne( [Tags("Smart Collections")]
[Required] [FromBody] [EndpointSummary("Create a smart collection")]
UpdateSmartCollection request) [EndpointGroupName("general")]
public async Task<IActionResult> Create(
[Required] [FromBody] CreateSmartCollectionRequest request,
CancellationToken cancellationToken)
{ {
Either<BaseError, UpdateSmartCollectionResult> result = await mediator.Send(request); Either<BaseError, SmartCollectionViewModel> result =
return result.Match<IActionResult>(Ok, error => Problem(error.ToString())); await mediator.Send(request.ToCommand(), cancellationToken);
return result.ToCreatedResult(vm => $"/api/smart-collections/{vm.Id}", vm => vm);
} }
[HttpDelete("/api/collections/smart/delete/{id:int}", Name="DeleteSmartCollection")] [HttpPut("/api/smart-collections/{id:int}")]
public async Task<IActionResult> DeleteSmartCollection(int id) [Tags("Smart Collections")]
[EndpointSummary("Update a smart collection")]
[EndpointGroupName("general")]
public async Task<IActionResult> Update(
int id,
[Required] [FromBody] UpdateSmartCollectionRequest request,
CancellationToken cancellationToken)
{ {
Either<BaseError, Unit> result = await mediator.Send(new DeleteSmartCollection(id)); Either<BaseError, UpdateSmartCollectionResult> result =
return result.Match<IActionResult>(_ => Ok(), error => Problem(error.ToString())); await mediator.Send(request.ToCommand(id), cancellationToken);
return await result.Match(
Left: error => Task.FromResult(error.ToErrorResult()),
Right: async _ =>
{
Option<SmartCollectionViewModel> smartCollection =
await mediator.Send(new GetSmartCollectionById(id), cancellationToken);
return smartCollection.Match(
Some: vm => (IActionResult)new OkObjectResult(vm),
None: () => new NotFoundResult());
});
}
[HttpDelete("/api/smart-collections/{id:int}")]
[Tags("Smart Collections")]
[EndpointSummary("Delete a smart collection")]
[EndpointGroupName("general")]
public async Task<IActionResult> Delete(int id, CancellationToken cancellationToken)
{
Either<BaseError, Unit> result = await mediator.Send(new DeleteSmartCollection(id), cancellationToken);
return result.ToDeletedResult();
} }
} }