Browse Source

add smart collections (#355)

* start to add smart collections

* add smart collection table; delete smart collection

* overwrite smart collections

* support scheduling smart collections

* update changelog
pull/356/head
Jason Dove 4 years ago committed by GitHub
parent
commit
fc360602ad
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 6
      CHANGELOG.md
  2. 9
      ErsatzTV.Application/MediaCollections/Commands/CreateSmartCollection.cs
  3. 70
      ErsatzTV.Application/MediaCollections/Commands/CreateSmartCollectionHandler.cs
  4. 7
      ErsatzTV.Application/MediaCollections/Commands/DeleteSmartCollection.cs
  5. 42
      ErsatzTV.Application/MediaCollections/Commands/DeleteSmartCollectionHandler.cs
  6. 2
      ErsatzTV.Application/MediaCollections/Commands/UpdateMultiCollectionHandler.cs
  7. 9
      ErsatzTV.Application/MediaCollections/Commands/UpdateSmartCollection.cs
  8. 71
      ErsatzTV.Application/MediaCollections/Commands/UpdateSmartCollectionHandler.cs
  9. 3
      ErsatzTV.Application/MediaCollections/Mapper.cs
  10. 6
      ErsatzTV.Application/MediaCollections/PagedSmartCollectionsViewModel.cs
  11. 7
      ErsatzTV.Application/MediaCollections/Queries/GetAllSmartCollections.cs
  12. 30
      ErsatzTV.Application/MediaCollections/Queries/GetAllSmartCollectionsHandler.cs
  13. 2
      ErsatzTV.Application/MediaCollections/Queries/GetPagedMultiCollectionsHandler.cs
  14. 6
      ErsatzTV.Application/MediaCollections/Queries/GetPagedSmartCollections.cs
  15. 46
      ErsatzTV.Application/MediaCollections/Queries/GetPagedSmartCollectionsHandler.cs
  16. 4
      ErsatzTV.Application/MediaCollections/SmartCollectionViewModel.cs
  17. 1
      ErsatzTV.Application/ProgramSchedules/Commands/AddProgramScheduleItem.cs
  18. 1
      ErsatzTV.Application/ProgramSchedules/Commands/IProgramScheduleItemRequest.cs
  19. 11
      ErsatzTV.Application/ProgramSchedules/Commands/ProgramScheduleItemCommandBase.cs
  20. 1
      ErsatzTV.Application/ProgramSchedules/Commands/ReplaceProgramScheduleItems.cs
  21. 6
      ErsatzTV.Application/ProgramSchedules/Commands/ReplaceProgramScheduleItemsHandler.cs
  22. 12
      ErsatzTV.Application/ProgramSchedules/Mapper.cs
  23. 2
      ErsatzTV.Application/ProgramSchedules/ProgramScheduleItemDurationViewModel.cs
  24. 2
      ErsatzTV.Application/ProgramSchedules/ProgramScheduleItemFloodViewModel.cs
  25. 2
      ErsatzTV.Application/ProgramSchedules/ProgramScheduleItemMultipleViewModel.cs
  26. 2
      ErsatzTV.Application/ProgramSchedules/ProgramScheduleItemOneViewModel.cs
  27. 3
      ErsatzTV.Application/ProgramSchedules/ProgramScheduleItemViewModel.cs
  28. 4
      ErsatzTV.Core.Tests/Fakes/FakeMediaCollectionRepository.cs
  29. 9
      ErsatzTV.Core/Domain/Collection/SmartCollection.cs
  30. 1
      ErsatzTV.Core/Domain/ConfigElementKey.cs
  31. 2
      ErsatzTV.Core/Domain/PlayoutProgramScheduleAnchor.cs
  32. 2
      ErsatzTV.Core/Domain/ProgramScheduleItem.cs
  33. 3
      ErsatzTV.Core/Domain/ProgramScheduleItemCollectionType.cs
  34. 2
      ErsatzTV.Core/Interfaces/Repositories/IMediaCollectionRepository.cs
  35. 13
      ErsatzTV.Core/Scheduling/PlayoutBuilder.cs
  36. 2
      ErsatzTV.Core/Search/SearchItem.cs
  37. 11
      ErsatzTV.Infrastructure/Data/Configurations/Collection/SmartCollectionConfiguration.cs
  38. 107
      ErsatzTV.Infrastructure/Data/Repositories/MediaCollectionRepository.cs
  39. 1
      ErsatzTV.Infrastructure/Data/TvContext.cs
  40. 3081
      ErsatzTV.Infrastructure/Migrations/20210910142324_Add_SmartCollection.Designer.cs
  41. 30
      ErsatzTV.Infrastructure/Migrations/20210910142324_Add_SmartCollection.cs
  42. 3092
      ErsatzTV.Infrastructure/Migrations/20210910163032_Add_ProgramScheduleItemSmartCollection.Designer.cs
  43. 44
      ErsatzTV.Infrastructure/Migrations/20210910163032_Add_ProgramScheduleItemSmartCollection.cs
  44. 3103
      ErsatzTV.Infrastructure/Migrations/20210910164712_Add_PlayoutProgramScheduleAnchorSmartCollection.Designer.cs
  45. 44
      ErsatzTV.Infrastructure/Migrations/20210910164712_Add_PlayoutProgramScheduleAnchorSmartCollection.cs
  46. 41
      ErsatzTV.Infrastructure/Migrations/TvContextModelSnapshot.cs
  47. 26
      ErsatzTV.Infrastructure/Search/SearchIndex.cs
  48. 2
      ErsatzTV/Pages/Artist.razor
  49. 67
      ErsatzTV/Pages/Collections.razor
  50. 26
      ErsatzTV/Pages/ScheduleItemsEditor.razor
  51. 52
      ErsatzTV/Pages/Search.razor
  52. 2
      ErsatzTV/Pages/TelevisionEpisodeList.razor
  53. 2
      ErsatzTV/Pages/TelevisionSeasonList.razor
  54. 119
      ErsatzTV/Shared/SaveAsSmartCollectionDialog.razor
  55. 2
      ErsatzTV/ViewModels/ProgramScheduleItemEditViewModel.cs

6
CHANGELOG.md

@ -4,12 +4,18 @@ All notable changes to this project will be documented in this file. @@ -4,12 +4,18 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
## [Unreleased]
### Added
- Add Smart Collections
- Smart Collections use search queries and can be created from the search result page
- Smart Collections are re-evaluated every time playouts are extended or rebuilt to automatically include newly-matching items
### Fixed
- Generate XMLTV that validates successfully
- Properly order elements
- Omit channels with no programmes
- Properly identify channels using the format number.etv like `15.etv`
- Fix building playouts when multi-part episode grouping is enabled and episodes are missing metadata
- Fix incorrect total items count in `Multi Collections` table
## [0.0.55-alpha] - 2021-09-03
### Fixed

9
ErsatzTV.Application/MediaCollections/Commands/CreateSmartCollection.cs

@ -0,0 +1,9 @@ @@ -0,0 +1,9 @@
using ErsatzTV.Core;
using LanguageExt;
using MediatR;
namespace ErsatzTV.Application.MediaCollections.Commands
{
public record CreateSmartCollection
(string Query, string Name) : IRequest<Either<BaseError, SmartCollectionViewModel>>;
}

70
ErsatzTV.Application/MediaCollections/Commands/CreateSmartCollectionHandler.cs

@ -0,0 +1,70 @@ @@ -0,0 +1,70 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Infrastructure.Data;
using LanguageExt;
using MediatR;
using Microsoft.EntityFrameworkCore;
using static ErsatzTV.Application.MediaCollections.Mapper;
using static LanguageExt.Prelude;
namespace ErsatzTV.Application.MediaCollections.Commands
{
public class CreateSmartCollectionHandler :
IRequestHandler<CreateSmartCollection, Either<BaseError, SmartCollectionViewModel>>
{
private readonly IDbContextFactory<TvContext> _dbContextFactory;
public CreateSmartCollectionHandler(IDbContextFactory<TvContext> dbContextFactory) =>
_dbContextFactory = dbContextFactory;
public async Task<Either<BaseError, SmartCollectionViewModel>> Handle(
CreateSmartCollection request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
Validation<BaseError, SmartCollection> validation = await Validate(dbContext, request);
return await validation.Apply(c => PersistCollection(dbContext, c));
}
private static async Task<SmartCollectionViewModel> PersistCollection(
TvContext dbContext,
SmartCollection smartCollection)
{
await dbContext.SmartCollections.AddAsync(smartCollection);
await dbContext.SaveChangesAsync();
return ProjectToViewModel(smartCollection);
}
private static Task<Validation<BaseError, SmartCollection>> Validate(
TvContext dbContext,
CreateSmartCollection request) =>
ValidateName(dbContext, request).MapT(
name => new SmartCollection
{
Name = name,
Query = request.Query
});
private static async Task<Validation<BaseError, string>> ValidateName(
TvContext dbContext,
CreateSmartCollection createSmartCollection)
{
List<string> allNames = await dbContext.SmartCollections
.Map(c => c.Name)
.ToListAsync();
Validation<BaseError, string> result1 = createSmartCollection.NotEmpty(c => c.Name)
.Bind(_ => createSmartCollection.NotLongerThan(50)(c => c.Name));
var result2 = Optional(createSmartCollection.Name)
.Filter(name => !allNames.Contains(name))
.ToValidation<BaseError>("SmartCollection name must be unique");
return (result1, result2).Apply((_, _) => createSmartCollection.Name);
}
}
}

7
ErsatzTV.Application/MediaCollections/Commands/DeleteSmartCollection.cs

@ -0,0 +1,7 @@ @@ -0,0 +1,7 @@
using ErsatzTV.Core;
using LanguageExt;
namespace ErsatzTV.Application.MediaCollections.Commands
{
public record DeleteSmartCollection(int SmartCollectionId) : MediatR.IRequest<Either<BaseError, Unit>>;
}

42
ErsatzTV.Application/MediaCollections/Commands/DeleteSmartCollectionHandler.cs

@ -0,0 +1,42 @@ @@ -0,0 +1,42 @@
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using LanguageExt;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.MediaCollections.Commands
{
public class DeleteSmartCollectionHandler : MediatR.IRequestHandler<DeleteSmartCollection, Either<BaseError, Unit>>
{
private readonly IDbContextFactory<TvContext> _dbContextFactory;
public DeleteSmartCollectionHandler(IDbContextFactory<TvContext> dbContextFactory) =>
_dbContextFactory = dbContextFactory;
public async Task<Either<BaseError, Unit>> Handle(
DeleteSmartCollection request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
Validation<BaseError, SmartCollection> validation = await SmartCollectionMustExist(dbContext, request);
return await validation.Apply(c => DoDeletion(dbContext, c));
}
private static Task<Unit> DoDeletion(TvContext dbContext, SmartCollection smartCollection)
{
dbContext.SmartCollections.Remove(smartCollection);
return dbContext.SaveChangesAsync().ToUnit();
}
private static Task<Validation<BaseError, SmartCollection>> SmartCollectionMustExist(
TvContext dbContext,
DeleteSmartCollection request) =>
dbContext.SmartCollections
.SelectOneAsync(c => c.Id, c => c.Id == request.SmartCollectionId)
.Map(o => o.ToValidation<BaseError>($"SmartCollection {request.SmartCollectionId} does not exist."));
}
}

2
ErsatzTV.Application/MediaCollections/Commands/UpdateMultiCollectionHandler.cs

@ -44,7 +44,7 @@ namespace ErsatzTV.Application.MediaCollections.Commands @@ -44,7 +44,7 @@ namespace ErsatzTV.Application.MediaCollections.Commands
{
c.Name = request.Name;
// save name first so playouts don't get rebuild for a name change
// save name first so playouts don't get rebuilt for a name change
await dbContext.SaveChangesAsync();
var toAdd = request.Items

9
ErsatzTV.Application/MediaCollections/Commands/UpdateSmartCollection.cs

@ -0,0 +1,9 @@ @@ -0,0 +1,9 @@
using ErsatzTV.Core;
using LanguageExt;
using MediatR;
using Unit = LanguageExt.Unit;
namespace ErsatzTV.Application.MediaCollections.Commands
{
public record UpdateSmartCollection(int Id, string Query) : IRequest<Either<BaseError, Unit>>;
}

71
ErsatzTV.Application/MediaCollections/Commands/UpdateSmartCollectionHandler.cs

@ -0,0 +1,71 @@ @@ -0,0 +1,71 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Channels;
using System.Threading.Tasks;
using ErsatzTV.Application.Playouts.Commands;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using LanguageExt;
using Microsoft.EntityFrameworkCore;
using static LanguageExt.Prelude;
namespace ErsatzTV.Application.MediaCollections.Commands
{
public class UpdateSmartCollectionHandler : MediatR.IRequestHandler<UpdateSmartCollection, Either<BaseError, Unit>>
{
private readonly ChannelWriter<IBackgroundServiceRequest> _channel;
private readonly IDbContextFactory<TvContext> _dbContextFactory;
private readonly IMediaCollectionRepository _mediaCollectionRepository;
public UpdateSmartCollectionHandler(
IDbContextFactory<TvContext> dbContextFactory,
IMediaCollectionRepository mediaCollectionRepository,
ChannelWriter<IBackgroundServiceRequest> channel)
{
_dbContextFactory = dbContextFactory;
_mediaCollectionRepository = mediaCollectionRepository;
_channel = channel;
}
public async Task<Either<BaseError, Unit>> Handle(
UpdateSmartCollection request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
Validation<BaseError, SmartCollection> validation = await Validate(dbContext, request);
return await validation.Apply(c => ApplyUpdateRequest(dbContext, c, request));
}
private async Task<Unit> ApplyUpdateRequest(TvContext dbContext, SmartCollection c, UpdateSmartCollection request)
{
c.Query = request.Query;
// rebuild playouts
if (await dbContext.SaveChangesAsync() > 0)
{
// rebuild all playouts that use this smart collection
foreach (int playoutId in await _mediaCollectionRepository.PlayoutIdsUsingSmartCollection(request.Id))
{
await _channel.WriteAsync(new BuildPlayout(playoutId, true));
}
}
return Unit.Default;
}
private static Task<Validation<BaseError, SmartCollection>> Validate(
TvContext dbContext,
UpdateSmartCollection request) => SmartCollectionMustExist(dbContext, request);
private static Task<Validation<BaseError, SmartCollection>> SmartCollectionMustExist(
TvContext dbContext,
UpdateSmartCollection updateCollection) =>
dbContext.SmartCollections
.SelectOneAsync(c => c.Id, c => c.Id == updateCollection.Id)
.Map(o => o.ToValidation<BaseError>("SmartCollection does not exist."));
}
}

3
ErsatzTV.Application/MediaCollections/Mapper.cs

@ -15,6 +15,9 @@ namespace ErsatzTV.Application.MediaCollections @@ -15,6 +15,9 @@ namespace ErsatzTV.Application.MediaCollections
multiCollection.Name,
Optional(multiCollection.MultiCollectionItems).Flatten().Map(ProjectToViewModel).ToList());
internal static SmartCollectionViewModel ProjectToViewModel(SmartCollection collection) =>
new(collection.Id, collection.Name, collection.Query);
private static MultiCollectionItemViewModel ProjectToViewModel(MultiCollectionItem multiCollectionItem) =>
new(
multiCollectionItem.MultiCollectionId,

6
ErsatzTV.Application/MediaCollections/PagedSmartCollectionsViewModel.cs

@ -0,0 +1,6 @@ @@ -0,0 +1,6 @@
using System.Collections.Generic;
namespace ErsatzTV.Application.MediaCollections
{
public record PagedSmartCollectionsViewModel(int TotalCount, List<SmartCollectionViewModel> Page);
}

7
ErsatzTV.Application/MediaCollections/Queries/GetAllSmartCollections.cs

@ -0,0 +1,7 @@ @@ -0,0 +1,7 @@
using System.Collections.Generic;
using MediatR;
namespace ErsatzTV.Application.MediaCollections.Queries
{
public record GetAllSmartCollections : IRequest<List<SmartCollectionViewModel>>;
}

30
ErsatzTV.Application/MediaCollections/Queries/GetAllSmartCollectionsHandler.cs

@ -0,0 +1,30 @@ @@ -0,0 +1,30 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Infrastructure.Data;
using LanguageExt;
using MediatR;
using Microsoft.EntityFrameworkCore;
using static ErsatzTV.Application.MediaCollections.Mapper;
namespace ErsatzTV.Application.MediaCollections.Queries
{
public class GetAllSmartCollectionsHandler : IRequestHandler<GetAllSmartCollections, List<SmartCollectionViewModel>>
{
private readonly IDbContextFactory<TvContext> _dbContextFactory;
public GetAllSmartCollectionsHandler(IDbContextFactory<TvContext> dbContextFactory) =>
_dbContextFactory = dbContextFactory;
public async Task<List<SmartCollectionViewModel>> Handle(
GetAllSmartCollections request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
return await dbContext.SmartCollections
.ToListAsync(cancellationToken)
.Map(list => list.Map(ProjectToViewModel).ToList());
}
}
}

2
ErsatzTV.Application/MediaCollections/Queries/GetPagedMultiCollectionsHandler.cs

@ -27,7 +27,7 @@ namespace ErsatzTV.Application.MediaCollections.Queries @@ -27,7 +27,7 @@ namespace ErsatzTV.Application.MediaCollections.Queries
GetPagedMultiCollections request,
CancellationToken cancellationToken)
{
int count = await _dbConnection.QuerySingleAsync<int>(@"SELECT COUNT (*) FROM Collection");
int count = await _dbConnection.QuerySingleAsync<int>(@"SELECT COUNT (*) FROM MultiCollection");
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
List<MultiCollectionViewModel> page = await dbContext.MultiCollections.FromSqlRaw(

6
ErsatzTV.Application/MediaCollections/Queries/GetPagedSmartCollections.cs

@ -0,0 +1,6 @@ @@ -0,0 +1,6 @@
using MediatR;
namespace ErsatzTV.Application.MediaCollections.Queries
{
public record GetPagedSmartCollections(int PageNum, int PageSize) : IRequest<PagedSmartCollectionsViewModel>;
}

46
ErsatzTV.Application/MediaCollections/Queries/GetPagedSmartCollectionsHandler.cs

@ -0,0 +1,46 @@ @@ -0,0 +1,46 @@
using System.Collections.Generic;
using System.Data;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Dapper;
using ErsatzTV.Infrastructure.Data;
using LanguageExt;
using MediatR;
using Microsoft.EntityFrameworkCore;
using static ErsatzTV.Application.MediaCollections.Mapper;
namespace ErsatzTV.Application.MediaCollections.Queries
{
public class GetPagedSmartCollectionsHandler : IRequestHandler<GetPagedSmartCollections, PagedSmartCollectionsViewModel>
{
private readonly IDbConnection _dbConnection;
private readonly IDbContextFactory<TvContext> _dbContextFactory;
public GetPagedSmartCollectionsHandler(IDbContextFactory<TvContext> dbContextFactory, IDbConnection dbConnection)
{
_dbContextFactory = dbContextFactory;
_dbConnection = dbConnection;
}
public async Task<PagedSmartCollectionsViewModel> Handle(
GetPagedSmartCollections request,
CancellationToken cancellationToken)
{
int count = await _dbConnection.QuerySingleAsync<int>(@"SELECT COUNT (*) FROM SmartCollection");
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
List<SmartCollectionViewModel> page = await dbContext.SmartCollections.FromSqlRaw(
@"SELECT * FROM SmartCollection
ORDER BY Name
COLLATE NOCASE
LIMIT {0} OFFSET {1}",
request.PageSize,
request.PageNum * request.PageSize)
.ToListAsync(cancellationToken)
.Map(list => list.Map(ProjectToViewModel).ToList());
return new PagedSmartCollectionsViewModel(count, page);
}
}
}

4
ErsatzTV.Application/MediaCollections/SmartCollectionViewModel.cs

@ -0,0 +1,4 @@ @@ -0,0 +1,4 @@
namespace ErsatzTV.Application.MediaCollections
{
public record SmartCollectionViewModel(int Id, string Name, string Query);
}

1
ErsatzTV.Application/ProgramSchedules/Commands/AddProgramScheduleItem.cs

@ -14,6 +14,7 @@ namespace ErsatzTV.Application.ProgramSchedules.Commands @@ -14,6 +14,7 @@ namespace ErsatzTV.Application.ProgramSchedules.Commands
ProgramScheduleItemCollectionType CollectionType,
int? CollectionId,
int? MultiCollectionId,
int? SmartCollectionId,
int? MediaItemId,
PlaybackOrder PlaybackOrder,
int? MultipleCount,

1
ErsatzTV.Application/ProgramSchedules/Commands/IProgramScheduleItemRequest.cs

@ -9,6 +9,7 @@ namespace ErsatzTV.Application.ProgramSchedules.Commands @@ -9,6 +9,7 @@ namespace ErsatzTV.Application.ProgramSchedules.Commands
ProgramScheduleItemCollectionType CollectionType { get; }
int? CollectionId { get; }
int? MultiCollectionId { get; }
int? SmartCollectionId { get; }
int? MediaItemId { get; }
PlayoutMode PlayoutMode { get; }
PlaybackOrder PlaybackOrder { get; }

11
ErsatzTV.Application/ProgramSchedules/Commands/ProgramScheduleItemCommandBase.cs

@ -112,6 +112,13 @@ namespace ErsatzTV.Application.ProgramSchedules.Commands @@ -112,6 +112,13 @@ namespace ErsatzTV.Application.ProgramSchedules.Commands
return BaseError.New("[MultiCollection] is required for collection type 'MultiCollection'");
}
break;
case ProgramScheduleItemCollectionType.SmartCollection:
if (item.SmartCollectionId is null)
{
return BaseError.New("[SmartCollection] is required for collection type 'SmartCollection'");
}
break;
default:
return BaseError.New("[CollectionType] is invalid");
@ -134,6 +141,7 @@ namespace ErsatzTV.Application.ProgramSchedules.Commands @@ -134,6 +141,7 @@ namespace ErsatzTV.Application.ProgramSchedules.Commands
CollectionType = item.CollectionType,
CollectionId = item.CollectionId,
MultiCollectionId = item.MultiCollectionId,
SmartCollectionId = item.SmartCollectionId,
MediaItemId = item.MediaItemId,
PlaybackOrder = item.PlaybackOrder,
CustomTitle = item.CustomTitle
@ -146,6 +154,7 @@ namespace ErsatzTV.Application.ProgramSchedules.Commands @@ -146,6 +154,7 @@ namespace ErsatzTV.Application.ProgramSchedules.Commands
CollectionType = item.CollectionType,
CollectionId = item.CollectionId,
MultiCollectionId = item.MultiCollectionId,
SmartCollectionId = item.SmartCollectionId,
MediaItemId = item.MediaItemId,
PlaybackOrder = item.PlaybackOrder,
CustomTitle = item.CustomTitle
@ -158,6 +167,7 @@ namespace ErsatzTV.Application.ProgramSchedules.Commands @@ -158,6 +167,7 @@ namespace ErsatzTV.Application.ProgramSchedules.Commands
CollectionType = item.CollectionType,
CollectionId = item.CollectionId,
MultiCollectionId = item.MultiCollectionId,
SmartCollectionId = item.SmartCollectionId,
MediaItemId = item.MediaItemId,
PlaybackOrder = item.PlaybackOrder,
Count = item.MultipleCount.GetValueOrDefault(),
@ -171,6 +181,7 @@ namespace ErsatzTV.Application.ProgramSchedules.Commands @@ -171,6 +181,7 @@ namespace ErsatzTV.Application.ProgramSchedules.Commands
CollectionType = item.CollectionType,
CollectionId = item.CollectionId,
MultiCollectionId = item.MultiCollectionId,
SmartCollectionId = item.SmartCollectionId,
MediaItemId = item.MediaItemId,
PlaybackOrder = item.PlaybackOrder,
PlayoutDuration = FixDuration(item.PlayoutDuration.GetValueOrDefault()),

1
ErsatzTV.Application/ProgramSchedules/Commands/ReplaceProgramScheduleItems.cs

@ -15,6 +15,7 @@ namespace ErsatzTV.Application.ProgramSchedules.Commands @@ -15,6 +15,7 @@ namespace ErsatzTV.Application.ProgramSchedules.Commands
ProgramScheduleItemCollectionType CollectionType,
int? CollectionId,
int? MultiCollectionId,
int? SmartCollectionId,
int? MediaItemId,
PlaybackOrder PlaybackOrder,
int? MultipleCount,

6
ErsatzTV.Application/ProgramSchedules/Commands/ReplaceProgramScheduleItemsHandler.cs

@ -88,7 +88,8 @@ namespace ErsatzTV.Application.ProgramSchedules.Commands @@ -88,7 +88,8 @@ namespace ErsatzTV.Application.ProgramSchedules.Commands
item.CollectionType,
item.CollectionId,
item.MediaItemId,
item.MultiCollectionId);
item.MultiCollectionId,
item.SmartCollectionId);
if (keyOrders.TryGetValue(key, out System.Collections.Generic.HashSet<PlaybackOrder> playbackOrders))
{
@ -111,6 +112,7 @@ namespace ErsatzTV.Application.ProgramSchedules.Commands @@ -111,6 +112,7 @@ namespace ErsatzTV.Application.ProgramSchedules.Commands
ProgramScheduleItemCollectionType CollectionType,
int? CollectionId,
int? MediaItemId,
int? MultiCollectionId);
int? MultiCollectionId,
int? SmartCollectionId);
}
}

12
ErsatzTV.Application/ProgramSchedules/Mapper.cs

@ -28,6 +28,9 @@ namespace ErsatzTV.Application.ProgramSchedules @@ -28,6 +28,9 @@ namespace ErsatzTV.Application.ProgramSchedules
duration.MultiCollection != null
? MediaCollections.Mapper.ProjectToViewModel(duration.MultiCollection)
: null,
duration.SmartCollection != null
? MediaCollections.Mapper.ProjectToViewModel(duration.SmartCollection)
: null,
duration.MediaItem switch
{
Show show => MediaItems.Mapper.ProjectToViewModel(show),
@ -52,6 +55,9 @@ namespace ErsatzTV.Application.ProgramSchedules @@ -52,6 +55,9 @@ namespace ErsatzTV.Application.ProgramSchedules
flood.MultiCollection != null
? MediaCollections.Mapper.ProjectToViewModel(flood.MultiCollection)
: null,
flood.SmartCollection != null
? MediaCollections.Mapper.ProjectToViewModel(flood.SmartCollection)
: null,
flood.MediaItem switch
{
Show show => MediaItems.Mapper.ProjectToViewModel(show),
@ -74,6 +80,9 @@ namespace ErsatzTV.Application.ProgramSchedules @@ -74,6 +80,9 @@ namespace ErsatzTV.Application.ProgramSchedules
multiple.MultiCollection != null
? MediaCollections.Mapper.ProjectToViewModel(multiple.MultiCollection)
: null,
multiple.SmartCollection != null
? MediaCollections.Mapper.ProjectToViewModel(multiple.SmartCollection)
: null,
multiple.MediaItem switch
{
Show show => MediaItems.Mapper.ProjectToViewModel(show),
@ -97,6 +106,9 @@ namespace ErsatzTV.Application.ProgramSchedules @@ -97,6 +106,9 @@ namespace ErsatzTV.Application.ProgramSchedules
one.MultiCollection != null
? MediaCollections.Mapper.ProjectToViewModel(one.MultiCollection)
: null,
one.SmartCollection != null
? MediaCollections.Mapper.ProjectToViewModel(one.SmartCollection)
: null,
one.MediaItem switch
{
Show show => MediaItems.Mapper.ProjectToViewModel(show),

2
ErsatzTV.Application/ProgramSchedules/ProgramScheduleItemDurationViewModel.cs

@ -15,6 +15,7 @@ namespace ErsatzTV.Application.ProgramSchedules @@ -15,6 +15,7 @@ namespace ErsatzTV.Application.ProgramSchedules
ProgramScheduleItemCollectionType collectionType,
MediaCollectionViewModel collection,
MultiCollectionViewModel multiCollection,
SmartCollectionViewModel smartCollection,
NamedMediaItemViewModel mediaItem,
PlaybackOrder playbackOrder,
TimeSpan playoutDuration,
@ -28,6 +29,7 @@ namespace ErsatzTV.Application.ProgramSchedules @@ -28,6 +29,7 @@ namespace ErsatzTV.Application.ProgramSchedules
collectionType,
collection,
multiCollection,
smartCollection,
mediaItem,
playbackOrder,
customTitle)

2
ErsatzTV.Application/ProgramSchedules/ProgramScheduleItemFloodViewModel.cs

@ -15,6 +15,7 @@ namespace ErsatzTV.Application.ProgramSchedules @@ -15,6 +15,7 @@ namespace ErsatzTV.Application.ProgramSchedules
ProgramScheduleItemCollectionType collectionType,
MediaCollectionViewModel collection,
MultiCollectionViewModel multiCollection,
SmartCollectionViewModel smartCollection,
NamedMediaItemViewModel mediaItem,
PlaybackOrder playbackOrder,
string customTitle) : base(
@ -26,6 +27,7 @@ namespace ErsatzTV.Application.ProgramSchedules @@ -26,6 +27,7 @@ namespace ErsatzTV.Application.ProgramSchedules
collectionType,
collection,
multiCollection,
smartCollection,
mediaItem,
playbackOrder,
customTitle)

2
ErsatzTV.Application/ProgramSchedules/ProgramScheduleItemMultipleViewModel.cs

@ -15,6 +15,7 @@ namespace ErsatzTV.Application.ProgramSchedules @@ -15,6 +15,7 @@ namespace ErsatzTV.Application.ProgramSchedules
ProgramScheduleItemCollectionType collectionType,
MediaCollectionViewModel collection,
MultiCollectionViewModel multiCollection,
SmartCollectionViewModel smartCollection,
NamedMediaItemViewModel mediaItem,
PlaybackOrder playbackOrder,
int count,
@ -27,6 +28,7 @@ namespace ErsatzTV.Application.ProgramSchedules @@ -27,6 +28,7 @@ namespace ErsatzTV.Application.ProgramSchedules
collectionType,
collection,
multiCollection,
smartCollection,
mediaItem,
playbackOrder,
customTitle) =>

2
ErsatzTV.Application/ProgramSchedules/ProgramScheduleItemOneViewModel.cs

@ -15,6 +15,7 @@ namespace ErsatzTV.Application.ProgramSchedules @@ -15,6 +15,7 @@ namespace ErsatzTV.Application.ProgramSchedules
ProgramScheduleItemCollectionType collectionType,
MediaCollectionViewModel collection,
MultiCollectionViewModel multiCollection,
SmartCollectionViewModel smartCollection,
NamedMediaItemViewModel mediaItem,
PlaybackOrder playbackOrder,
string customTitle) : base(
@ -26,6 +27,7 @@ namespace ErsatzTV.Application.ProgramSchedules @@ -26,6 +27,7 @@ namespace ErsatzTV.Application.ProgramSchedules
collectionType,
collection,
multiCollection,
smartCollection,
mediaItem,
playbackOrder,
customTitle)

3
ErsatzTV.Application/ProgramSchedules/ProgramScheduleItemViewModel.cs

@ -14,6 +14,7 @@ namespace ErsatzTV.Application.ProgramSchedules @@ -14,6 +14,7 @@ namespace ErsatzTV.Application.ProgramSchedules
ProgramScheduleItemCollectionType CollectionType,
MediaCollectionViewModel Collection,
MultiCollectionViewModel MultiCollection,
SmartCollectionViewModel SmartCollection,
NamedMediaItemViewModel MediaItem,
PlaybackOrder PlaybackOrder,
string CustomTitle)
@ -29,6 +30,8 @@ namespace ErsatzTV.Application.ProgramSchedules @@ -29,6 +30,8 @@ namespace ErsatzTV.Application.ProgramSchedules
MediaItem?.Name,
ProgramScheduleItemCollectionType.MultiCollection =>
MultiCollection?.Name,
ProgramScheduleItemCollectionType.SmartCollection =>
SmartCollection?.Name,
_ => string.Empty
};
}

4
ErsatzTV.Core.Tests/Fakes/FakeMediaCollectionRepository.cs

@ -20,6 +20,7 @@ namespace ErsatzTV.Core.Tests.Fakes @@ -20,6 +20,7 @@ namespace ErsatzTV.Core.Tests.Fakes
public Task<List<MediaItem>> GetItems(int id) => _data[id].ToList().AsTask();
public Task<List<MediaItem>> GetMultiCollectionItems(int id) => throw new NotSupportedException();
public Task<List<MediaItem>> GetSmartCollectionItems(int id) => throw new NotSupportedException();
public Task<List<CollectionWithItems>> GetMultiCollectionCollections(int id) =>
throw new NotSupportedException();
@ -29,6 +30,9 @@ namespace ErsatzTV.Core.Tests.Fakes @@ -29,6 +30,9 @@ namespace ErsatzTV.Core.Tests.Fakes
public Task<List<int>> PlayoutIdsUsingMultiCollection(int multiCollectionId) =>
throw new NotSupportedException();
public Task<List<int>> PlayoutIdsUsingSmartCollection(int smartCollectionId) =>
throw new NotSupportedException();
public Task<bool> IsCustomPlaybackOrder(int collectionId) => false.AsTask();
}
}

9
ErsatzTV.Core/Domain/Collection/SmartCollection.cs

@ -0,0 +1,9 @@ @@ -0,0 +1,9 @@
namespace ErsatzTV.Core.Domain
{
public class SmartCollection
{
public int Id { get; set; }
public string Name { get; set; }
public string Query { get; set; }
}
}

1
ErsatzTV.Core/Domain/ConfigElementKey.cs

@ -18,6 +18,7 @@ @@ -18,6 +18,7 @@
public static ConfigElementKey ChannelsPageSize => new("pages.channels.page_size");
public static ConfigElementKey CollectionsPageSize => new("pages.collections.page_size");
public static ConfigElementKey MultiCollectionsPageSize => new("pages.multi_collections.page_size");
public static ConfigElementKey SmartCollectionsPageSize => new("pages.smart_collections.page_size");
public static ConfigElementKey SchedulesPageSize => new("pages.schedules.page_size");
public static ConfigElementKey SchedulesDetailPageSize => new("pages.schedules.detail_page_size");
public static ConfigElementKey PlayoutsPageSize => new("pages.playouts.page_size");

2
ErsatzTV.Core/Domain/PlayoutProgramScheduleAnchor.cs

@ -12,6 +12,8 @@ @@ -12,6 +12,8 @@
public Collection Collection { get; set; }
public int? MultiCollectionId { get; set; }
public MultiCollection MultiCollection { get; set; }
public int? SmartCollectionId { get; set; }
public SmartCollection SmartCollection { get; set; }
public int? MediaItemId { get; set; }
public MediaItem MediaItem { get; set; }
public CollectionEnumeratorState EnumeratorState { get; set; }

2
ErsatzTV.Core/Domain/ProgramScheduleItem.cs

@ -18,6 +18,8 @@ namespace ErsatzTV.Core.Domain @@ -18,6 +18,8 @@ namespace ErsatzTV.Core.Domain
public MediaItem MediaItem { get; set; }
public int? MultiCollectionId { get; set; }
public MultiCollection MultiCollection { get; set; }
public int? SmartCollectionId { get; set; }
public SmartCollection SmartCollection { get; set; }
public PlaybackOrder PlaybackOrder { get; set; }
}
}

3
ErsatzTV.Core/Domain/ProgramScheduleItemCollectionType.cs

@ -6,6 +6,7 @@ @@ -6,6 +6,7 @@
TelevisionShow = 1,
TelevisionSeason = 2,
Artist = 3,
MultiCollection = 4
MultiCollection = 4,
SmartCollection = 5
}
}

2
ErsatzTV.Core/Interfaces/Repositories/IMediaCollectionRepository.cs

@ -11,9 +11,11 @@ namespace ErsatzTV.Core.Interfaces.Repositories @@ -11,9 +11,11 @@ namespace ErsatzTV.Core.Interfaces.Repositories
Task<Option<Collection>> GetCollectionWithCollectionItemsUntracked(int id);
Task<List<MediaItem>> GetItems(int id);
Task<List<MediaItem>> GetMultiCollectionItems(int id);
Task<List<MediaItem>> GetSmartCollectionItems(int id);
Task<List<CollectionWithItems>> GetMultiCollectionCollections(int id);
Task<List<int>> PlayoutIdsUsingCollection(int collectionId);
Task<List<int>> PlayoutIdsUsingMultiCollection(int multiCollectionId);
Task<List<int>> PlayoutIdsUsingSmartCollection(int smartCollectionId);
Task<bool> IsCustomPlaybackOrder(int collectionId);
}
}

13
ErsatzTV.Core/Scheduling/PlayoutBuilder.cs

@ -91,6 +91,11 @@ namespace ErsatzTV.Core.Scheduling @@ -91,6 +91,11 @@ namespace ErsatzTV.Core.Scheduling
await _mediaCollectionRepository.GetMultiCollectionItems(
collectionKey.MultiCollectionId ?? 0);
return Tuple(collectionKey, multiCollectionItems);
case ProgramScheduleItemCollectionType.SmartCollection:
List<MediaItem> smartCollectionItems =
await _mediaCollectionRepository.GetSmartCollectionItems(
collectionKey.SmartCollectionId ?? 0);
return Tuple(collectionKey, smartCollectionItems);
default:
return Tuple(collectionKey, new List<MediaItem>());
}
@ -514,6 +519,7 @@ namespace ErsatzTV.Core.Scheduling @@ -514,6 +519,7 @@ namespace ErsatzTV.Core.Scheduling
CollectionType = collectionKey.CollectionType,
CollectionId = collectionKey.CollectionId,
MultiCollectionId = collectionKey.MultiCollectionId,
SmartCollectionId = collectionKey.SmartCollectionId,
MediaItemId = collectionKey.MediaItemId,
EnumeratorState = maybeEnumeratorState[collectionKey]
});
@ -535,6 +541,7 @@ namespace ErsatzTV.Core.Scheduling @@ -535,6 +541,7 @@ namespace ErsatzTV.Core.Scheduling
&& a.CollectionType == collectionKey.CollectionType
&& a.CollectionId == collectionKey.CollectionId
&& a.MultiCollectionId == collectionKey.MultiCollectionId
&& a.SmartCollectionId == collectionKey.SmartCollectionId
&& a.MediaItemId == collectionKey.MediaItemId);
CollectionEnumeratorState state = maybeAnchor.Match(
@ -661,6 +668,11 @@ namespace ErsatzTV.Core.Scheduling @@ -661,6 +668,11 @@ namespace ErsatzTV.Core.Scheduling
CollectionType = item.CollectionType,
MultiCollectionId = item.MultiCollectionId
},
ProgramScheduleItemCollectionType.SmartCollection => new CollectionKey
{
CollectionType = item.CollectionType,
SmartCollectionId = item.SmartCollectionId
},
_ => throw new ArgumentOutOfRangeException(nameof(item))
};
@ -669,6 +681,7 @@ namespace ErsatzTV.Core.Scheduling @@ -669,6 +681,7 @@ namespace ErsatzTV.Core.Scheduling
public ProgramScheduleItemCollectionType CollectionType { get; set; }
public int? CollectionId { get; set; }
public int? MultiCollectionId { get; set; }
public int? SmartCollectionId { get; set; }
public int? MediaItemId { get; set; }
}
}

2
ErsatzTV.Core/Search/SearchItem.cs

@ -1,4 +1,4 @@ @@ -1,4 +1,4 @@
namespace ErsatzTV.Core.Search
{
public record SearchItem(int Id);
public record SearchItem(string Type, int Id);
}

11
ErsatzTV.Infrastructure/Data/Configurations/Collection/SmartCollectionConfiguration.cs

@ -0,0 +1,11 @@ @@ -0,0 +1,11 @@
using ErsatzTV.Core.Domain;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ErsatzTV.Infrastructure.Data.Configurations
{
public class SmartCollectionConfiguration : IEntityTypeConfiguration<SmartCollection>
{
public void Configure(EntityTypeBuilder<SmartCollection> builder) => builder.ToTable("SmartCollection");
}
}

107
ErsatzTV.Infrastructure/Data/Repositories/MediaCollectionRepository.cs

@ -5,8 +5,11 @@ using System.Threading.Tasks; @@ -5,8 +5,11 @@ using System.Threading.Tasks;
using Dapper;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Core.Scheduling;
using ErsatzTV.Core.Search;
using ErsatzTV.Infrastructure.Extensions;
using ErsatzTV.Infrastructure.Search;
using LanguageExt;
using Microsoft.EntityFrameworkCore;
using static LanguageExt.Prelude;
@ -16,12 +19,15 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -16,12 +19,15 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
public class MediaCollectionRepository : IMediaCollectionRepository
{
private readonly IDbConnection _dbConnection;
private readonly ISearchIndex _searchIndex;
private readonly IDbContextFactory<TvContext> _dbContextFactory;
public MediaCollectionRepository(
ISearchIndex searchIndex,
IDbContextFactory<TvContext> dbContextFactory,
IDbConnection dbConnection)
{
_searchIndex = searchIndex;
_dbContextFactory = dbContextFactory;
_dbConnection = dbConnection;
}
@ -78,6 +84,53 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -78,6 +84,53 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
return result.Distinct().ToList();
}
public async Task<List<MediaItem>> GetSmartCollectionItems(int id)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
var result = new List<MediaItem>();
Option<SmartCollection> maybeCollection = await dbContext.SmartCollections
.SelectOneAsync(sc => sc.Id, sc => sc.Id == id);
foreach (SmartCollection collection in maybeCollection)
{
SearchResult searchResults = await _searchIndex.Search(collection.Query, 0, 0);
var movieIds = searchResults.Items
.Filter(i => i.Type == SearchIndex.MovieType)
.Map(i => i.Id)
.ToList();
result.AddRange(await GetMovieItems(dbContext, movieIds));
var showIds = searchResults.Items
.Filter(i => i.Type == SearchIndex.ShowType)
.Map(i => i.Id)
.ToList();
result.AddRange(await GetShowItems(dbContext, showIds));
var artistIds = searchResults.Items
.Filter(i => i.Type == SearchIndex.ArtistType)
.Map(i => i.Id)
.ToList();
result.AddRange(await GetArtistItems(dbContext, artistIds));
var musicVideoIds = searchResults.Items
.Filter(i => i.Type == SearchIndex.MusicVideoType)
.Map(i => i.Id)
.ToList();
result.AddRange(await GetMusicVideoItems(dbContext, musicVideoIds));
var episodeIds = searchResults.Items
.Filter(i => i.Type == SearchIndex.EpisodeType)
.Map(i => i.Id)
.ToList();
result.AddRange(await GetEpisodeItems(dbContext, episodeIds));
}
return result;
}
public async Task<List<CollectionWithItems>> GetMultiCollectionCollections(int id)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
@ -159,6 +212,14 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -159,6 +212,14 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
new { MultiCollectionId = multiCollectionId })
.Map(result => result.ToList());
public Task<List<int>> PlayoutIdsUsingSmartCollection(int smartCollectionId) =>
_dbConnection.QueryAsync<int>(
@"SELECT DISTINCT p.PlayoutId
FROM PlayoutProgramScheduleAnchor p
WHERE p.SmartCollectionId = @SmartCollectionId",
new { SmartCollectionId = smartCollectionId })
.Map(result => result.ToList());
public Task<bool> IsCustomPlaybackOrder(int collectionId) =>
_dbConnection.QuerySingleAsync<bool>(
@"SELECT IFNULL(MIN(UseCustomPlaybackOrder), 0) FROM Collection WHERE Id = @CollectionId",
@ -172,12 +233,15 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -172,12 +233,15 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
WHERE ci.CollectionId = @CollectionId",
new { CollectionId = collectionId });
return await dbContext.Movies
return await GetMovieItems(dbContext, ids);
}
private static Task<List<Movie>> GetMovieItems(TvContext dbContext, IEnumerable<int> movieIds) =>
dbContext.Movies
.Include(m => m.MovieMetadata)
.Include(m => m.MediaVersions)
.Filter(m => ids.Contains(m.Id))
.Filter(m => movieIds.Contains(m.Id))
.ToListAsync();
}
private async Task<List<MusicVideo>> GetArtistItems(TvContext dbContext, int collectionId)
{
@ -188,15 +252,17 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -188,15 +252,17 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
WHERE ci.CollectionId = @CollectionId",
new { CollectionId = collectionId });
return await dbContext.MusicVideos
return await GetArtistItems(dbContext, ids);
}
private static Task<List<MusicVideo>> GetArtistItems(TvContext dbContext, IEnumerable<int> artistIds) =>
dbContext.MusicVideos
.Include(m => m.Artist)
.ThenInclude(a => a.ArtistMetadata)
.Include(m => m.MusicVideoMetadata)
.Include(m => m.MediaVersions)
.Filter(m => ids.Contains(m.Id))
.Filter(m => artistIds.Contains(m.Id))
.ToListAsync();
}
private async Task<List<MusicVideo>> GetMusicVideoItems(TvContext dbContext, int collectionId)
{
@ -206,14 +272,17 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -206,14 +272,17 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
WHERE ci.CollectionId = @CollectionId",
new { CollectionId = collectionId });
return await dbContext.MusicVideos
return await GetMusicVideoItems(dbContext, ids);
}
private static Task<List<MusicVideo>> GetMusicVideoItems(TvContext dbContext, IEnumerable<int> musicVideoIds) =>
dbContext.MusicVideos
.Include(m => m.Artist)
.ThenInclude(a => a.ArtistMetadata)
.Include(m => m.MusicVideoMetadata)
.Include(m => m.MediaVersions)
.Filter(m => ids.Contains(m.Id))
.Filter(m => musicVideoIds.Contains(m.Id))
.ToListAsync();
}
private async Task<List<Episode>> GetShowItems(TvContext dbContext, int collectionId)
{
@ -225,15 +294,18 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -225,15 +294,18 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
WHERE ci.CollectionId = @CollectionId",
new { CollectionId = collectionId });
return await dbContext.Episodes
return await GetShowItems(dbContext, ids);
}
private static Task<List<Episode>> GetShowItems(TvContext dbContext, IEnumerable<int> showIds) =>
dbContext.Episodes
.Include(e => e.EpisodeMetadata)
.Include(e => e.MediaVersions)
.Include(e => e.Season)
.ThenInclude(s => s.Show)
.ThenInclude(s => s.ShowMetadata)
.Filter(e => ids.Contains(e.Id))
.Filter(e => showIds.Contains(e.Id))
.ToListAsync();
}
private async Task<List<Episode>> GetSeasonItems(TvContext dbContext, int collectionId)
{
@ -262,14 +334,17 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -262,14 +334,17 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
WHERE ci.CollectionId = @CollectionId",
new { CollectionId = collectionId });
return await dbContext.Episodes
return await GetEpisodeItems(dbContext, ids);
}
private static Task<List<Episode>> GetEpisodeItems(TvContext dbContext, IEnumerable<int> episodeIds) =>
dbContext.Episodes
.Include(e => e.EpisodeMetadata)
.Include(e => e.MediaVersions)
.Include(e => e.Season)
.ThenInclude(s => s.Show)
.ThenInclude(s => s.ShowMetadata)
.Filter(e => ids.Contains(e.Id))
.Filter(e => episodeIds.Contains(e.Id))
.ToListAsync();
}
}
}

1
ErsatzTV.Infrastructure/Data/TvContext.cs

@ -59,6 +59,7 @@ namespace ErsatzTV.Infrastructure.Data @@ -59,6 +59,7 @@ namespace ErsatzTV.Infrastructure.Data
public DbSet<Collection> Collections { get; set; }
public DbSet<CollectionItem> CollectionItems { get; set; }
public DbSet<MultiCollection> MultiCollections { get; set; }
public DbSet<SmartCollection> SmartCollections { get; set; }
public DbSet<ProgramSchedule> ProgramSchedules { get; set; }
public DbSet<ProgramScheduleItem> ProgramScheduleItems { get; set; }
public DbSet<Playout> Playouts { get; set; }

3081
ErsatzTV.Infrastructure/Migrations/20210910142324_Add_SmartCollection.Designer.cs generated

File diff suppressed because it is too large Load Diff

30
ErsatzTV.Infrastructure/Migrations/20210910142324_Add_SmartCollection.cs

@ -0,0 +1,30 @@ @@ -0,0 +1,30 @@
using Microsoft.EntityFrameworkCore.Migrations;
namespace ErsatzTV.Infrastructure.Migrations
{
public partial class Add_SmartCollection : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "SmartCollection",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
Name = table.Column<string>(type: "TEXT", nullable: true),
Query = table.Column<string>(type: "TEXT", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_SmartCollection", x => x.Id);
});
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "SmartCollection");
}
}
}

3092
ErsatzTV.Infrastructure/Migrations/20210910163032_Add_ProgramScheduleItemSmartCollection.Designer.cs generated

File diff suppressed because it is too large Load Diff

44
ErsatzTV.Infrastructure/Migrations/20210910163032_Add_ProgramScheduleItemSmartCollection.cs

@ -0,0 +1,44 @@ @@ -0,0 +1,44 @@
using Microsoft.EntityFrameworkCore.Migrations;
namespace ErsatzTV.Infrastructure.Migrations
{
public partial class Add_ProgramScheduleItemSmartCollection : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "SmartCollectionId",
table: "ProgramScheduleItem",
type: "INTEGER",
nullable: true);
migrationBuilder.CreateIndex(
name: "IX_ProgramScheduleItem_SmartCollectionId",
table: "ProgramScheduleItem",
column: "SmartCollectionId");
migrationBuilder.AddForeignKey(
name: "FK_ProgramScheduleItem_SmartCollection_SmartCollectionId",
table: "ProgramScheduleItem",
column: "SmartCollectionId",
principalTable: "SmartCollection",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_ProgramScheduleItem_SmartCollection_SmartCollectionId",
table: "ProgramScheduleItem");
migrationBuilder.DropIndex(
name: "IX_ProgramScheduleItem_SmartCollectionId",
table: "ProgramScheduleItem");
migrationBuilder.DropColumn(
name: "SmartCollectionId",
table: "ProgramScheduleItem");
}
}
}

3103
ErsatzTV.Infrastructure/Migrations/20210910164712_Add_PlayoutProgramScheduleAnchorSmartCollection.Designer.cs generated

File diff suppressed because it is too large Load Diff

44
ErsatzTV.Infrastructure/Migrations/20210910164712_Add_PlayoutProgramScheduleAnchorSmartCollection.cs

@ -0,0 +1,44 @@ @@ -0,0 +1,44 @@
using Microsoft.EntityFrameworkCore.Migrations;
namespace ErsatzTV.Infrastructure.Migrations
{
public partial class Add_PlayoutProgramScheduleAnchorSmartCollection : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "SmartCollectionId",
table: "PlayoutProgramScheduleAnchor",
type: "INTEGER",
nullable: true);
migrationBuilder.CreateIndex(
name: "IX_PlayoutProgramScheduleAnchor_SmartCollectionId",
table: "PlayoutProgramScheduleAnchor",
column: "SmartCollectionId");
migrationBuilder.AddForeignKey(
name: "FK_PlayoutProgramScheduleAnchor_SmartCollection_SmartCollectionId",
table: "PlayoutProgramScheduleAnchor",
column: "SmartCollectionId",
principalTable: "SmartCollection",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_PlayoutProgramScheduleAnchor_SmartCollection_SmartCollectionId",
table: "PlayoutProgramScheduleAnchor");
migrationBuilder.DropIndex(
name: "IX_PlayoutProgramScheduleAnchor_SmartCollectionId",
table: "PlayoutProgramScheduleAnchor");
migrationBuilder.DropColumn(
name: "SmartCollectionId",
table: "PlayoutProgramScheduleAnchor");
}
}
}

41
ErsatzTV.Infrastructure/Migrations/TvContextModelSnapshot.cs

@ -14,7 +14,7 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -14,7 +14,7 @@ namespace ErsatzTV.Infrastructure.Migrations
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "5.0.8");
.HasAnnotation("ProductVersion", "5.0.9");
modelBuilder.Entity("ErsatzTV.Core.Domain.Actor", b =>
{
@ -1106,6 +1106,9 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -1106,6 +1106,9 @@ namespace ErsatzTV.Infrastructure.Migrations
b.Property<int>("ProgramScheduleId")
.HasColumnType("INTEGER");
b.Property<int?>("SmartCollectionId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("CollectionId");
@ -1118,6 +1121,8 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -1118,6 +1121,8 @@ namespace ErsatzTV.Infrastructure.Migrations
b.HasIndex("ProgramScheduleId");
b.HasIndex("SmartCollectionId");
b.ToTable("PlayoutProgramScheduleAnchor");
});
@ -1218,6 +1223,9 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -1218,6 +1223,9 @@ namespace ErsatzTV.Infrastructure.Migrations
b.Property<int>("ProgramScheduleId")
.HasColumnType("INTEGER");
b.Property<int?>("SmartCollectionId")
.HasColumnType("INTEGER");
b.Property<TimeSpan?>("StartTime")
.HasColumnType("TEXT");
@ -1231,6 +1239,8 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -1231,6 +1239,8 @@ namespace ErsatzTV.Infrastructure.Migrations
b.HasIndex("ProgramScheduleId");
b.HasIndex("SmartCollectionId");
b.ToTable("ProgramScheduleItem");
});
@ -1349,6 +1359,23 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -1349,6 +1359,23 @@ namespace ErsatzTV.Infrastructure.Migrations
b.ToTable("ShowMetadata");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.SmartCollection", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Name")
.HasColumnType("TEXT");
b.Property<string>("Query")
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("SmartCollection");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.Studio", b =>
{
b.Property<int>("Id")
@ -2338,6 +2365,10 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -2338,6 +2365,10 @@ namespace ErsatzTV.Infrastructure.Migrations
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("ErsatzTV.Core.Domain.SmartCollection", "SmartCollection")
.WithMany()
.HasForeignKey("SmartCollectionId");
b.OwnsOne("ErsatzTV.Core.Domain.CollectionEnumeratorState", "EnumeratorState", b1 =>
{
b1.Property<int>("PlayoutProgramScheduleAnchorId")
@ -2368,6 +2399,8 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -2368,6 +2399,8 @@ namespace ErsatzTV.Infrastructure.Migrations
b.Navigation("Playout");
b.Navigation("ProgramSchedule");
b.Navigation("SmartCollection");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.PlexConnection", b =>
@ -2415,6 +2448,10 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -2415,6 +2448,10 @@ namespace ErsatzTV.Infrastructure.Migrations
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("ErsatzTV.Core.Domain.SmartCollection", "SmartCollection")
.WithMany()
.HasForeignKey("SmartCollectionId");
b.Navigation("Collection");
b.Navigation("MediaItem");
@ -2422,6 +2459,8 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -2422,6 +2459,8 @@ namespace ErsatzTV.Infrastructure.Migrations
b.Navigation("MultiCollection");
b.Navigation("ProgramSchedule");
b.Navigation("SmartCollection");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.SeasonMetadata", b =>

26
ErsatzTV.Infrastructure/Search/SearchIndex.cs

@ -52,11 +52,11 @@ namespace ErsatzTV.Infrastructure.Search @@ -52,11 +52,11 @@ namespace ErsatzTV.Infrastructure.Search
private const string DirectorField = "director";
private const string WriterField = "writer";
private const string MovieType = "movie";
private const string ShowType = "show";
private const string ArtistType = "artist";
private const string MusicVideoType = "music_video";
private const string EpisodeType = "episode";
public const string MovieType = "movie";
public const string ShowType = "show";
public const string ArtistType = "artist";
public const string MusicVideoType = "music_video";
public const string EpisodeType = "episode";
private readonly List<CultureInfo> _cultureInfos;
private readonly ILogger<SearchIndex> _logger;
@ -72,7 +72,7 @@ namespace ErsatzTV.Infrastructure.Search @@ -72,7 +72,7 @@ namespace ErsatzTV.Infrastructure.Search
_initialized = false;
}
public int Version => 14;
public int Version => 15;
public Task<bool> Initialize(ILocalFileSystem localFileSystem)
{
@ -275,7 +275,7 @@ namespace ErsatzTV.Infrastructure.Search @@ -275,7 +275,7 @@ namespace ErsatzTV.Infrastructure.Search
var doc = new Document
{
new StringField(IdField, movie.Id.ToString(), Field.Store.YES),
new StringField(TypeField, MovieType, Field.Store.NO),
new StringField(TypeField, MovieType, Field.Store.YES),
new TextField(TitleField, metadata.Title, Field.Store.NO),
new StringField(SortTitleField, metadata.SortTitle.ToLowerInvariant(), Field.Store.NO),
new TextField(LibraryNameField, movie.LibraryPath.Library.Name, Field.Store.NO),
@ -395,7 +395,7 @@ namespace ErsatzTV.Infrastructure.Search @@ -395,7 +395,7 @@ namespace ErsatzTV.Infrastructure.Search
var doc = new Document
{
new StringField(IdField, show.Id.ToString(), Field.Store.YES),
new StringField(TypeField, ShowType, Field.Store.NO),
new StringField(TypeField, ShowType, Field.Store.YES),
new TextField(TitleField, metadata.Title, Field.Store.NO),
new StringField(SortTitleField, metadata.SortTitle.ToLowerInvariant(), Field.Store.NO),
new TextField(LibraryNameField, show.LibraryPath.Library.Name, Field.Store.NO),
@ -472,7 +472,7 @@ namespace ErsatzTV.Infrastructure.Search @@ -472,7 +472,7 @@ namespace ErsatzTV.Infrastructure.Search
var doc = new Document
{
new StringField(IdField, artist.Id.ToString(), Field.Store.YES),
new StringField(TypeField, ArtistType, Field.Store.NO),
new StringField(TypeField, ArtistType, Field.Store.YES),
new TextField(TitleField, metadata.Title, Field.Store.NO),
new StringField(SortTitleField, metadata.SortTitle.ToLowerInvariant(), Field.Store.NO),
new TextField(LibraryNameField, artist.LibraryPath.Library.Name, Field.Store.NO),
@ -521,7 +521,7 @@ namespace ErsatzTV.Infrastructure.Search @@ -521,7 +521,7 @@ namespace ErsatzTV.Infrastructure.Search
var doc = new Document
{
new StringField(IdField, musicVideo.Id.ToString(), Field.Store.YES),
new StringField(TypeField, MusicVideoType, Field.Store.NO),
new StringField(TypeField, MusicVideoType, Field.Store.YES),
new TextField(TitleField, metadata.Title, Field.Store.NO),
new StringField(SortTitleField, metadata.SortTitle.ToLowerInvariant(), Field.Store.NO),
new TextField(LibraryNameField, musicVideo.LibraryPath.Library.Name, Field.Store.NO),
@ -590,7 +590,7 @@ namespace ErsatzTV.Infrastructure.Search @@ -590,7 +590,7 @@ namespace ErsatzTV.Infrastructure.Search
var doc = new Document();
doc.Add(new StringField(IdField, episode.Id.ToString(), Field.Store.YES));
doc.Add(new StringField(TypeField, EpisodeType, Field.Store.NO));
doc.Add(new StringField(TypeField, EpisodeType, Field.Store.YES));
doc.Add(new TextField(TitleField, metadata.Title, Field.Store.NO));
doc.Add(new StringField(SortTitleField, metadata.SortTitle.ToLowerInvariant(), Field.Store.NO));
doc.Add(new TextField(LibraryNameField, episode.LibraryPath.Library.Name, Field.Store.NO));
@ -654,7 +654,9 @@ namespace ErsatzTV.Infrastructure.Search @@ -654,7 +654,9 @@ namespace ErsatzTV.Infrastructure.Search
}
}
private SearchItem ProjectToSearchItem(Document doc) => new(Convert.ToInt32(doc.Get(IdField)));
private SearchItem ProjectToSearchItem(Document doc) => new(
doc.Get(TypeField),
Convert.ToInt32(doc.Get(IdField)));
private Query ParseQuery(string searchQuery, QueryParser parser)
{

2
ErsatzTV/Pages/Artist.razor

@ -206,7 +206,7 @@ @@ -206,7 +206,7 @@
DialogResult result = await dialog.Result;
if (!result.Cancelled && result.Data is ProgramScheduleViewModel schedule)
{
await _mediator.Send(new AddProgramScheduleItem(schedule.Id, StartType.Dynamic, null, PlayoutMode.One, ProgramScheduleItemCollectionType.Artist, null, null, ArtistId, PlaybackOrder.Shuffle, null, null, null, null));
await _mediator.Send(new AddProgramScheduleItem(schedule.Id, StartType.Dynamic, null, PlayoutMode.One, ProgramScheduleItemCollectionType.Artist, null, null, null, ArtistId, PlaybackOrder.Shuffle, null, null, null, null));
_navigationManager.NavigateTo($"/schedules/{schedule.Id}/items");
}
}

67
ErsatzTV/Pages/Collections.razor

@ -3,8 +3,8 @@ @@ -3,8 +3,8 @@
@using ErsatzTV.Application.MediaCollections.Commands
@using ErsatzTV.Application.MediaCollections.Queries
@using ErsatzTV.Application.Configuration.Queries
@using ErsatzTV.Application.MediaCards
@using ErsatzTV.Application.Configuration.Commands
@using ErsatzTV.Extensions
@inject IDialogService _dialog
@inject IMediator _mediator
@ -93,14 +93,54 @@ @@ -93,14 +93,54 @@
<MudTablePager/>
</PagerContent>
</MudTable>
<MudTable Class="mt-4"
Hover="true"
@bind-RowsPerPage="@_smartCollectionsRowsPerPage"
ServerData="@(new Func<TableState, Task<TableData<SmartCollectionViewModel>>>(ServerReloadSmartCollections))"
Dense="true"
@ref="_smartCollectionsTable">
<ToolBarContent>
<MudText Typo="Typo.h6">Smart Collections</MudText>
</ToolBarContent>
<ColGroup>
<col/>
<col style="width: 120px;"/>
</ColGroup>
<HeaderContent>
<MudTh>Name</MudTh>
<MudTh/>
</HeaderContent>
<RowTemplate>
<MudTd DataLabel="Name">@context.Name</MudTd>
<MudTd>
<div style="align-items: center; display: flex;">
<MudTooltip Text="Edit Collection">
<MudIconButton Icon="@Icons.Material.Filled.Edit"
Link="@context.Query.GetRelativeSearchQuery()">
</MudIconButton>
</MudTooltip>
<MudTooltip Text="Delete Collection">
<MudIconButton Icon="@Icons.Material.Filled.Delete"
OnClick="@(_ => DeleteSmartCollection(context))">
</MudIconButton>
</MudTooltip>
</div>
</MudTd>
</RowTemplate>
<PagerContent>
<MudTablePager/>
</PagerContent>
</MudTable>
</MudContainer>
@code {
private MudTable<MediaCollectionViewModel> _collectionsTable;
private MudTable<MultiCollectionViewModel> _multiCollectionsTable;
private MudTable<SmartCollectionViewModel> _smartCollectionsTable;
private int _collectionsRowsPerPage;
private int _multiCollectionsRowsPerPage;
private int _smartCollectionsRowsPerPage;
protected override async Task OnParametersSetAsync()
{
@ -109,6 +149,9 @@ @@ -109,6 +149,9 @@
_multiCollectionsRowsPerPage = await _mediator.Send(new GetConfigElementByKey(ConfigElementKey.MultiCollectionsPageSize))
.Map(maybeRows => maybeRows.Match(ce => int.TryParse(ce.Value, out int rows) ? rows : 10, () => 10));
_smartCollectionsRowsPerPage = await _mediator.Send(new GetConfigElementByKey(ConfigElementKey.SmartCollectionsPageSize))
.Map(maybeRows => maybeRows.Match(ce => int.TryParse(ce.Value, out int rows) ? rows : 10, () => 10));
}
private async Task DeleteMediaCollection(MediaCollectionViewModel collection)
@ -138,6 +181,20 @@ @@ -138,6 +181,20 @@
await _multiCollectionsTable.ReloadServerData();
}
}
private async Task DeleteSmartCollection(SmartCollectionViewModel collection)
{
var parameters = new DialogParameters { { "EntityType", "smart collection" }, { "EntityName", collection.Name } };
var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall };
IDialogReference dialog = _dialog.Show<DeleteDialog>("Delete Smart Collection", parameters, options);
DialogResult result = await dialog.Result;
if (!result.Cancelled)
{
await _mediator.Send(new DeleteSmartCollection(collection.Id));
await _smartCollectionsTable.ReloadServerData();
}
}
private async Task<TableData<MediaCollectionViewModel>> ServerReloadCollections(TableState state)
{
@ -155,4 +212,12 @@ @@ -155,4 +212,12 @@
return new TableData<MultiCollectionViewModel> { TotalItems = data.TotalCount, Items = data.Page };
}
private async Task<TableData<SmartCollectionViewModel>> ServerReloadSmartCollections(TableState state)
{
await _mediator.Send(new SaveConfigElementByKey(ConfigElementKey.SmartCollectionsPageSize, state.PageSize.ToString()));
PagedSmartCollectionsViewModel data = await _mediator.Send(new GetPagedSmartCollections(state.Page, state.PageSize));
return new TableData<SmartCollectionViewModel> { TotalItems = data.TotalCount, Items = data.Page };
}
}

26
ErsatzTV/Pages/ScheduleItemsEditor.razor

@ -118,6 +118,15 @@ @@ -118,6 +118,15 @@
SearchFunc="@SearchMultiCollections"
ToStringFunc="@(c => c?.Name)"/>
}
@if (_selectedItem.CollectionType == ProgramScheduleItemCollectionType.SmartCollection)
{
<MudAutocomplete Class="mt-3"
T="SmartCollectionViewModel"
Label="Smart Collection"
@bind-value="_selectedItem.SmartCollection"
SearchFunc="@SearchSmartCollections"
ToStringFunc="@(c => c?.Name)"/>
}
@if (_selectedItem.CollectionType == ProgramScheduleItemCollectionType.TelevisionShow)
{
<MudAutocomplete Class="mt-3"
@ -156,6 +165,7 @@ @@ -156,6 +165,7 @@
<MudSelectItem Value="PlaybackOrder.Chronological">Chronological</MudSelectItem>
<MudSelectItem Value="PlaybackOrder.Random">Random</MudSelectItem>
<MudSelectItem Value="PlaybackOrder.Shuffle">Shuffle</MudSelectItem>
@*<MudSelectItem Value="PlaybackOrder.ShuffleInOrder">Shuffle In Order</MudSelectItem>*@
}
</MudSelect>
<MudSelect Class="mt-3" Label="Playout Mode" @bind-Value="@_selectedItem.PlayoutMode" For="@(() => _selectedItem.PlayoutMode)">
@ -205,6 +215,7 @@ @@ -205,6 +215,7 @@
private ProgramScheduleItemsEditViewModel _schedule;
private List<MediaCollectionViewModel> _mediaCollections;
private List<MultiCollectionViewModel> _multiCollections;
private List<SmartCollectionViewModel> _smartCollections;
private List<NamedMediaItemViewModel> _televisionShows;
private List<NamedMediaItemViewModel> _televisionSeasons;
private List<NamedMediaItemViewModel> _artists;
@ -220,6 +231,8 @@ @@ -220,6 +231,8 @@
.Map(list => list.OrderBy(vm => vm.Name, StringComparer.CurrentCultureIgnoreCase).ToList());
_multiCollections = await _mediator.Send(new GetAllMultiCollections())
.Map(list => list.OrderBy(vm => vm.Name, StringComparer.CurrentCultureIgnoreCase).ToList());
_smartCollections = await _mediator.Send(new GetAllSmartCollections())
.Map(list => list.OrderBy(vm => vm.Name, StringComparer.CurrentCultureIgnoreCase).ToList());
_televisionShows = await _mediator.Send(new GetAllTelevisionShows())
.Map(list => list.OrderBy(vm => vm.Name, StringComparer.CurrentCultureIgnoreCase).ToList());
_televisionSeasons = await _mediator.Send(new GetAllTelevisionSeasons())
@ -251,6 +264,7 @@ @@ -251,6 +264,7 @@
CollectionType = item.CollectionType,
Collection = item.Collection,
MultiCollection = item.MultiCollection,
SmartCollection = item.SmartCollection,
MediaItem = item.MediaItem,
PlaybackOrder = item.PlaybackOrder,
CustomTitle = item.CustomTitle
@ -295,18 +309,14 @@ @@ -295,18 +309,14 @@
{
// swap with lower index
ProgramScheduleItemEditViewModel toSwap = _schedule.Items.OrderByDescending(x => x.Index).First(x => x.Index < item.Index);
int temp = toSwap.Index;
toSwap.Index = item.Index;
item.Index = temp;
(toSwap.Index, item.Index) = (item.Index, toSwap.Index);
}
private void MoveItemDown(ProgramScheduleItemEditViewModel item)
{
// swap with higher index
ProgramScheduleItemEditViewModel toSwap = _schedule.Items.OrderBy(x => x.Index).First(x => x.Index > item.Index);
int temp = toSwap.Index;
toSwap.Index = item.Index;
item.Index = temp;
(toSwap.Index, item.Index) = (item.Index, toSwap.Index);
}
private Task<IEnumerable<MediaCollectionViewModel>> SearchMediaCollections(string value) =>
@ -315,6 +325,9 @@ @@ -315,6 +325,9 @@
private Task<IEnumerable<MultiCollectionViewModel>> SearchMultiCollections(string value) =>
_multiCollections.Filter(c => c.Name.Contains(value ?? string.Empty, StringComparison.OrdinalIgnoreCase)).AsTask();
private Task<IEnumerable<SmartCollectionViewModel>> SearchSmartCollections(string value) =>
_smartCollections.Filter(c => c.Name.Contains(value ?? string.Empty, StringComparison.OrdinalIgnoreCase)).AsTask();
private Task<IEnumerable<NamedMediaItemViewModel>> SearchTelevisionShows(string value) =>
_televisionShows.Filter(s => s.Name.Contains(value ?? string.Empty, StringComparison.OrdinalIgnoreCase)).AsTask();
@ -334,6 +347,7 @@ @@ -334,6 +347,7 @@
item.CollectionType,
item.Collection?.Id,
item.MultiCollection?.Id,
item.SmartCollection?.Id,
item.MediaItem?.MediaItemId,
item.PlaybackOrder,
item.MultipleCount,

52
ErsatzTV/Pages/Search.razor

@ -5,7 +5,6 @@ @@ -5,7 +5,6 @@
@using ErsatzTV.Application.Search
@using ErsatzTV.Application.Search.Queries
@using ErsatzTV.Extensions
@using Microsoft.AspNetCore.WebUtilities
@using Unit = LanguageExt.Unit
@inherits MultiSelectBase<Search>
@inject NavigationManager _navigationManager
@ -60,12 +59,22 @@ @@ -60,12 +59,22 @@
<MudLink Class="ml-4" Href="@(_navigationManager.Uri.Split("#").Head() + "#music_videos")" Style="margin-bottom: auto; margin-top: auto">@_musicVideos.Count Music Videos</MudLink>
}
<div style="margin-left: auto">
<MudButton Variant="Variant.Filled"
Color="Color.Primary"
StartIcon="@Icons.Material.Filled.Add"
OnClick="@AddAllToCollection">
Add All To Collection
</MudButton>
<MudTooltip Text="Add All To Collection">
<MudButton Variant="Variant.Filled"
Color="Color.Primary"
StartIcon="@Icons.Material.Filled.Add"
OnClick="@AddAllToCollection">
Add All
</MudButton>
</MudTooltip>
<MudTooltip Text="Save As Smart Collection">
<MudButton Class="ml-3" Variant="Variant.Filled"
Color="Color.Secondary"
StartIcon="@Icons.Material.Filled.Save"
OnClick="@SaveAsSmartCollection">
Save As
</MudButton>
</MudTooltip>
</div>
}
</div>
@ -415,4 +424,33 @@ @@ -415,4 +424,33 @@
await AddItemsToCollection(results.MovieIds, results.ShowIds, results.EpisodeIds, results.ArtistIds, results.MusicVideoIds, "search results");
}
private async Task SaveAsSmartCollection(MouseEventArgs _)
{
var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall };
IDialogReference dialog = Dialog.Show<SaveAsSmartCollectionDialog>("Save As Smart Collection", options);
DialogResult result = await dialog.Result;
if (!result.Cancelled && result.Data is SmartCollectionViewModel collection)
{
var request = new UpdateSmartCollection(
collection.Id,
_query);
Either<BaseError, Unit> updateResult = await Mediator.Send(request);
updateResult.Match(
Left: error =>
{
Snackbar.Add($"Unexpected error saving smart collection: {error.Value}");
Logger.LogError("Unexpected error saving smart collection: {Error}", error.Value);
},
Right: _ =>
{
Snackbar.Add(
$"Saved smart collection {collection.Name}",
Severity.Success);
ClearSelection();
});
}
}
}

2
ErsatzTV/Pages/TelevisionEpisodeList.razor

@ -197,7 +197,7 @@ @@ -197,7 +197,7 @@
DialogResult result = await dialog.Result;
if (!result.Cancelled && result.Data is ProgramScheduleViewModel schedule)
{
await _mediator.Send(new AddProgramScheduleItem(schedule.Id, StartType.Dynamic, null, PlayoutMode.One, ProgramScheduleItemCollectionType.TelevisionSeason, null, null, SeasonId, PlaybackOrder.Shuffle, null, null, null, null));
await _mediator.Send(new AddProgramScheduleItem(schedule.Id, StartType.Dynamic, null, PlayoutMode.One, ProgramScheduleItemCollectionType.TelevisionSeason, null, null, null, SeasonId, PlaybackOrder.Shuffle, null, null, null, null));
_navigationManager.NavigateTo($"/schedules/{schedule.Id}/items");
}
}

2
ErsatzTV/Pages/TelevisionSeasonList.razor

@ -225,7 +225,7 @@ @@ -225,7 +225,7 @@
DialogResult result = await dialog.Result;
if (!result.Cancelled && result.Data is ProgramScheduleViewModel schedule)
{
await _mediator.Send(new AddProgramScheduleItem(schedule.Id, StartType.Dynamic, null, PlayoutMode.One, ProgramScheduleItemCollectionType.TelevisionShow, null, null, ShowId, PlaybackOrder.Shuffle, null, null, null, null));
await _mediator.Send(new AddProgramScheduleItem(schedule.Id, StartType.Dynamic, null, PlayoutMode.One, ProgramScheduleItemCollectionType.TelevisionShow, null, null, null, ShowId, PlaybackOrder.Shuffle, null, null, null, null));
_navigationManager.NavigateTo($"/schedules/{schedule.Id}/items");
}
}

119
ErsatzTV/Shared/SaveAsSmartCollectionDialog.razor

@ -0,0 +1,119 @@ @@ -0,0 +1,119 @@
@using Microsoft.Extensions.Caching.Memory
@using ErsatzTV.Application.MediaCollections
@using ErsatzTV.Application.MediaCollections.Commands
@using ErsatzTV.Application.MediaCollections.Queries
@inject IMediator _mediator
@inject IMemoryCache _memoryCache
@inject ISnackbar _snackbar
@inject ILogger<SaveAsSmartCollectionDialog> _logger
<MudDialog>
<DialogContent>
<EditForm Model="@_dummyModel" OnSubmit="@(_ => Submit())">
<MudContainer Class="mb-6">
<MudText Class="mud-primary-text"
Style="background-color: transparent; font-weight: bold"
Text="Select the desired smart collection"/>
</MudContainer>
<MudSelect Label="Collection" @bind-Value="_selectedCollection" Class="mb-6 mx-4">
@foreach (SmartCollectionViewModel collection in _collections)
{
<MudSelectItem Value="@collection">@collection.Name</MudSelectItem>
}
</MudSelect>
<MudTextFieldString Label="New Collection Name"
Disabled="@(_selectedCollection != _newCollection)"
@bind-Text="@_newCollectionName"
Class="mb-6 mx-4">
</MudTextFieldString>
</EditForm>
</DialogContent>
<DialogActions>
<MudButton OnClick="Cancel" ButtonType="ButtonType.Reset">Cancel</MudButton>
<MudButton Color="Color.Primary" Variant="Variant.Filled" OnClick="Submit">
Save As Smart Collection
</MudButton>
</DialogActions>
</MudDialog>
@code {
[CascadingParameter]
MudDialogInstance MudDialog { get; set; }
private readonly SmartCollectionViewModel _newCollection = new(-1, "(New Collection)", string.Empty);
private string _newCollectionName;
private List<SmartCollectionViewModel> _collections;
private SmartCollectionViewModel _selectedCollection;
private record DummyModel;
private readonly DummyModel _dummyModel = new();
private bool CanSubmit() =>
_selectedCollection != null && (_selectedCollection != _newCollection || !string.IsNullOrWhiteSpace(_newCollectionName));
protected override async Task OnParametersSetAsync()
{
_collections = await _mediator.Send(new GetAllSmartCollections())
.Map(list => new[] { _newCollection }.Append(list.OrderBy(vm => vm.Name, StringComparer.CurrentCultureIgnoreCase)).ToList());
if (_memoryCache.TryGetValue("SaveAsSmartCollectionDialog.SelectedCollectionId", out int id))
{
_selectedCollection = _collections.SingleOrDefault(c => c.Id == id) ?? _newCollection;
}
else
{
_selectedCollection = _newCollection;
}
}
private async Task Submit()
{
if (!CanSubmit())
{
return;
}
if (_selectedCollection == _newCollection)
{
Either<BaseError, SmartCollectionViewModel> maybeResult =
await _mediator.Send(new CreateSmartCollection(string.Empty, _newCollectionName));
maybeResult.Match(
collection =>
{
_memoryCache.Set("SaveAsSmartCollectionDialog.SelectedCollectionId", collection.Id);
MudDialog.Close(DialogResult.Ok(collection));
},
error =>
{
_snackbar.Add(error.Value, Severity.Error);
_logger.LogError("Error creating new collection: {Error}", error.Value);
MudDialog.Close(DialogResult.Cancel());
});
}
else
{
_memoryCache.Set("SaveAsSmartCollectionDialog.SelectedCollectionId", _selectedCollection.Id);
MudDialog.Close(DialogResult.Ok(_selectedCollection));
}
}
private async Task Cancel(MouseEventArgs e)
{
// this is gross, but [enter] seems to sometimes trigger cancel instead of submit
if (e.Detail == 0)
{
await Submit();
}
else
{
MudDialog.Cancel();
}
}
}

2
ErsatzTV/ViewModels/ProgramScheduleItemEditViewModel.cs

@ -55,6 +55,7 @@ namespace ErsatzTV.ViewModels @@ -55,6 +55,7 @@ namespace ErsatzTV.ViewModels
public MediaCollectionViewModel Collection { get; set; }
public MultiCollectionViewModel MultiCollection { get; set; }
public SmartCollectionViewModel SmartCollection { get; set; }
public NamedMediaItemViewModel MediaItem { get; set; }
public string CollectionName => CollectionType switch
@ -64,6 +65,7 @@ namespace ErsatzTV.ViewModels @@ -64,6 +65,7 @@ namespace ErsatzTV.ViewModels
ProgramScheduleItemCollectionType.TelevisionSeason => MediaItem?.Name,
ProgramScheduleItemCollectionType.Artist => MediaItem?.Name,
ProgramScheduleItemCollectionType.MultiCollection => MultiCollection?.Name,
ProgramScheduleItemCollectionType.SmartCollection => SmartCollection?.Name,
_ => string.Empty
};

Loading…
Cancel
Save