Compare commits

...

31 Commits

Author SHA1 Message Date
Jason Dove
b9912b47df update changelog for release 58 [no ci] 2021-09-15 21:53:34 -05:00
Jason Dove
55fb2624e7 add multi-part grouping tooltip (#371) 2021-09-15 21:18:41 -05:00
Jason Dove
8ced20dc39 dont offset collections during shuffle in order (#370) 2021-09-15 20:54:32 -05:00
Jason Dove
e718cb0faf fix building playouts in timezones with positive offsets (#368) 2021-09-15 09:07:35 -05:00
Jason Dove
e218ff9a6d fix watermark when no video filters are required (#367) 2021-09-15 05:12:23 -05:00
Jason Dove
c2a49cbaea update dependencies (#365) 2021-09-14 18:10:59 -05:00
Jason Dove
17e74f7314 add more release date search options (#362) 2021-09-12 18:32:38 -05:00
Long-Man
2032bb4777 Update search.md (#361)
Add release_date and released_nointhelast to music video search
2021-09-12 12:18:07 -05:00
Jason Dove
7877ec641e update changelog for release 57 [no ci] 2021-09-11 10:37:23 -05:00
Jason Dove
767a9779bb more kodi artwork fixes (#360) 2021-09-11 10:08:33 -05:00
Jason Dove
bb9127e546 fix artwork in kodi (#359) 2021-09-11 09:45:09 -05:00
Jason Dove
c932577cb8 allow adding smart collections to multi collections (#358) 2021-09-11 09:29:20 -05:00
Jason Dove
ad2685fb2e add released_inthelast queries (#357) 2021-09-10 21:11:14 -05:00
Jason Dove
96bc2c28f2 update changelog for release 56 [no ci] 2021-09-10 13:59:14 -05:00
Jason Dove
a076b3eb30 add shuffle-in-order support to all collections (#356) 2021-09-10 13:33:04 -05:00
Jason Dove
fc360602ad 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
2021-09-10 11:58:24 -05:00
Jason Dove
d8b4d00a73 clarify changelog [no ci] 2021-09-09 08:22:46 -05:00
Jason Dove
0638ac8a5e more missing metadata fixes (#354)
* more missing metadata fixes

* update mudblazor
2021-09-09 06:38:46 -05:00
Jason Dove
f1f09bd4cb fix sorting episodes without metadata (#353) 2021-09-08 22:12:47 -05:00
Jason Dove
f6680f29e7 try to fix doc formatting [no docker] 2021-09-07 13:36:25 -05:00
Jason Dove
1c0413452b fix m3u xmltv mapping 2021-09-07 06:34:17 -05:00
Jason Dove
77308a9ac5 generate valid xmltv (#351) 2021-09-07 06:12:13 -05:00
Jason Dove
3ea8193bb3 update changelog for release 55 [no ci] 2021-09-03 08:56:25 -05:00
Jason Dove
8ad8680027 update dependencies; fix unnecessary table scrolling (#347) 2021-09-03 06:22:50 -05:00
Jason Dove
640044814c ignore dot-underscore files (#346) 2021-09-03 06:22:33 -05:00
Jason Dove
18b5313a53 update docs [no docker] 2021-08-22 20:17:39 -05:00
Jason Dove
8417c3f6cd update changelog for release 54 [no ci] 2021-08-21 13:19:13 -05:00
Jason Dove
32fdb414fa add "shuffle in order" playback order for multi-collections (#338)
* add "shuffle in order" option for multi-collections

* use balanced shuffle instead of random
2021-08-21 12:47:22 -05:00
Jason Dove
d3fc820aef update dependencies (#336)
* update dependencies

* fix fluent assertions
2021-08-21 06:23:43 -05:00
Jason Dove
9d07627781 fix ffprobe parsing in some cultures (#337) 2021-08-21 05:57:39 -05:00
Jason Dove
d3c8914758 update dependencies (#331) 2021-08-14 07:20:09 -05:00
114 changed files with 14784 additions and 228 deletions

View File

@@ -5,6 +5,64 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
## [Unreleased]
## [0.0.58-alpha] - 2021-09-15
### Added
- Add `released_notinthelast` search field for relative release date queries
- Syntax is a number and a unit (days, weeks, months, years) like `1 week` or `2 years`
- Add `released_onthisday` search field for historical queries
- Syntax is `released_onthisday:1` and will search for items released on this month number and day number in prior years
- Add tooltip explaining `Keep Multi-Part Episodes Together`
### Fixed
- Properly display watermark when no other video filters (like scaling or padding) are required
- Fix building some playouts in timezones with positive offsets (like UTC+2)
- Fix `Shuffle In Order` so all collections/shows start from the earliest episode
- You may need to rebuild playouts to see this fixed behavior more quickly
## [0.0.57-alpha] - 2021-09-10
### Added
- Add `released_inthelast` search field for relative release date queries
- Syntax is a number and a unit (days, weeks, months, years) like `1 week` or `2 years`
- Allow adding smart collections to multi collections
### Fixed
- Fix loading artwork in Kodi
- Use fake image extension (`.jpg`) for artwork in M3U and XMLTV since Kodi detects MIME type from URL
- Enable HEAD requests for IPTV image paths since Kodi requires those
## [0.0.56-alpha] - 2021-09-10
### 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
- This requires rebuilding the search index and search results may be empty or incomplete until the rebuild is complete
- Allow `Shuffle In Order` with Collections and Smart Collections
- Episodes will be grouped by show, and music videos will be grouped by artist
- All movies will be a single group (multi-collections are probably better if `Shuffle In Order` is desired for movies)
- All groups will be be ordered chronologically (custom ordering is only supported in multi-collections)
### 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
- Fix all local library scanners to ignore dot underscore files (`._`)
## [0.0.54-alpha] - 2021-08-21
### Added
- Add `Shuffle In Order` playback order for multi-collections.
- This is useful for randomizing multiple collections/shows on a single channel, while each collection maintains proper ordering (custom or chronological)
### Fixed
- Fix bug parsing ffprobe output in cultures where `.` is a group/thousands separator
- This bug likely prevented ETV from scheduling correctly or working at all in those cultures
- After installing a version with this fix, affected content will need to be removed from ETV and re-added
## [0.0.53-alpha] - 2021-08-01
### Fixed
- Fix error message displayed after building empty playout
@@ -536,7 +594,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- Initial release to facilitate testing outside of Docker.
[Unreleased]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.53-alpha...HEAD
[Unreleased]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.58-alpha...HEAD
[0.0.58-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.57-alpha...v0.0.57-alpha
[0.0.57-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.56-alpha...v0.0.57-alpha
[0.0.56-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.55-alpha...v0.0.56-alpha
[0.0.55-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.54-alpha...v0.0.55-alpha
[0.0.54-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.53-alpha...v0.0.54-alpha
[0.0.53-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.52-alpha...v0.0.53-alpha
[0.0.52-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.51-alpha...v0.0.52-alpha
[0.0.51-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.50-alpha...v0.0.51-alpha

View File

@@ -67,7 +67,7 @@ namespace ErsatzTV.Application.Emby.Commands
private async Task<Unit> Synchronize(RequestParameters parameters)
{
var lastScan = new DateTimeOffset(parameters.Library.LastScan ?? DateTime.MinValue, TimeSpan.Zero);
var lastScan = new DateTimeOffset(parameters.Library.LastScan ?? SystemTime.MinValueUtc, TimeSpan.Zero);
DateTimeOffset nextScan = lastScan + TimeSpan.FromHours(parameters.LibraryRefreshInterval);
if (parameters.ForceScan || nextScan < DateTimeOffset.Now)
{

View File

@@ -67,7 +67,7 @@ namespace ErsatzTV.Application.Jellyfin.Commands
private async Task<Unit> Synchronize(RequestParameters parameters)
{
var lastScan = new DateTimeOffset(parameters.Library.LastScan ?? DateTime.MinValue, TimeSpan.Zero);
var lastScan = new DateTimeOffset(parameters.Library.LastScan ?? SystemTime.MinValueUtc, TimeSpan.Zero);
DateTimeOffset nextScan = lastScan + TimeSpan.FromHours(parameters.LibraryRefreshInterval);
if (parameters.ForceScan || nextScan < DateTimeOffset.Now)
{

View File

@@ -1,5 +1,6 @@
using System;
using System.Linq;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Emby;
using ErsatzTV.Core.Jellyfin;
@@ -43,7 +44,7 @@ namespace ErsatzTV.Application.MediaCards
bool isSearchResult) =>
new(
episodeMetadata.EpisodeId,
episodeMetadata.ReleaseDate ?? DateTime.MinValue,
episodeMetadata.ReleaseDate ?? SystemTime.MinValueUtc,
episodeMetadata.Episode.Season.Show.ShowMetadata.HeadOrNone().Match(
m => m.Title ?? string.Empty,
() => string.Empty),

View File

@@ -6,7 +6,7 @@ using MediatR;
namespace ErsatzTV.Application.MediaCollections.Commands
{
public record CreateMultiCollectionItem(int CollectionId, bool ScheduleAsGroup, PlaybackOrder PlaybackOrder);
public record CreateMultiCollectionItem(int? CollectionId, int? SmartCollectionId, bool ScheduleAsGroup, PlaybackOrder PlaybackOrder);
public record CreateMultiCollection
(string Name, List<CreateMultiCollectionItem> Items) : IRequest<Either<BaseError, MultiCollectionViewModel>>;

View File

@@ -41,6 +41,11 @@ namespace ErsatzTV.Application.MediaCollections.Commands
.Query()
.Include(i => i.Collection)
.LoadAsync();
await dbContext.Entry(multiCollection)
.Collection(c => c.MultiCollectionSmartItems)
.Query()
.Include(i => i.SmartCollection)
.LoadAsync();
return ProjectToViewModel(multiCollection);
}
@@ -51,12 +56,44 @@ namespace ErsatzTV.Application.MediaCollections.Commands
name => new MultiCollection
{
Name = name,
MultiCollectionItems = request.Items.Map(i => new MultiCollectionItem
{
CollectionId = i.CollectionId,
ScheduleAsGroup = i.ScheduleAsGroup,
PlaybackOrder = i.PlaybackOrder
}).ToList()
MultiCollectionItems = request.Items.Map(
i =>
{
if (i.CollectionId.HasValue)
{
return Some(
new MultiCollectionItem
{
CollectionId = i.CollectionId.Value,
ScheduleAsGroup = i.ScheduleAsGroup,
PlaybackOrder = i.PlaybackOrder
});
}
return None;
})
.Sequence()
.Flatten()
.ToList(),
MultiCollectionSmartItems = request.Items.Map(
i =>
{
if (i.SmartCollectionId.HasValue)
{
return Some(
new MultiCollectionSmartItem
{
SmartCollectionId = i.SmartCollectionId.Value,
ScheduleAsGroup = i.ScheduleAsGroup,
PlaybackOrder = i.PlaybackOrder
});
}
return None;
})
.Sequence()
.Flatten()
.ToList()
});
private static async Task<Validation<BaseError, string>> ValidateName(

View File

@@ -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>>;
}

View File

@@ -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);
}
}
}

View File

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

View File

@@ -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."));
}
}

View File

@@ -5,7 +5,7 @@ using LanguageExt;
namespace ErsatzTV.Application.MediaCollections.Commands
{
public record UpdateMultiCollectionItem(int CollectionId, bool ScheduleAsGroup, PlaybackOrder PlaybackOrder);
public record UpdateMultiCollectionItem(int? CollectionId, int? SmartCollectionId, bool ScheduleAsGroup, PlaybackOrder PlaybackOrder);
public record UpdateMultiCollection
(

View File

@@ -1,4 +1,5 @@
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Threading;
using System.Threading.Channels;
@@ -44,15 +45,17 @@ 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
.Filter(i => c.MultiCollectionItems.All(i2 => i2.CollectionId != i.CollectionId))
.Map(
i => new MultiCollectionItem
.Filter(i => i.CollectionId.HasValue)
// ReSharper disable once PossibleInvalidOperationException
.Filter(i => c.MultiCollectionItems.All(i2 => i2.CollectionId != i.CollectionId.Value))
.Map(i => new MultiCollectionItem
{
CollectionId = i.CollectionId,
// ReSharper disable once PossibleInvalidOperationException
CollectionId = i.CollectionId.Value,
MultiCollectionId = c.Id,
ScheduleAsGroup = i.ScheduleAsGroup,
PlaybackOrder = i.PlaybackOrder
@@ -79,6 +82,40 @@ namespace ErsatzTV.Application.MediaCollections.Commands
// add new items
c.MultiCollectionItems.AddRange(toAdd);
var toAddSmart = request.Items
.Filter(i => i.SmartCollectionId.HasValue)
// ReSharper disable once PossibleInvalidOperationException
.Filter(i => c.MultiCollectionSmartItems.All(i2 => i2.SmartCollectionId != i.SmartCollectionId.Value))
.Map(i => new MultiCollectionSmartItem
{
// ReSharper disable once PossibleInvalidOperationException
SmartCollectionId = i.SmartCollectionId.Value,
MultiCollectionId = c.Id,
ScheduleAsGroup = i.ScheduleAsGroup,
PlaybackOrder = i.PlaybackOrder
})
.ToList();
var toRemoveSmart = c.MultiCollectionSmartItems
.Filter(i => request.Items.All(i2 => i2.SmartCollectionId != i.SmartCollectionId))
.ToList();
// remove items that are no longer present
c.MultiCollectionSmartItems.RemoveAll(toRemoveSmart.Contains);
// update existing items
foreach (MultiCollectionSmartItem item in c.MultiCollectionSmartItems)
{
foreach (UpdateMultiCollectionItem incoming in request.Items.Filter(
i => i.SmartCollectionId == item.SmartCollectionId))
{
item.ScheduleAsGroup = incoming.ScheduleAsGroup;
item.PlaybackOrder = incoming.PlaybackOrder;
}
}
// add new items
c.MultiCollectionSmartItems.AddRange(toAddSmart);
// rebuild playouts
if (await dbContext.SaveChangesAsync() > 0)
{
@@ -104,6 +141,7 @@ namespace ErsatzTV.Application.MediaCollections.Commands
UpdateMultiCollection updateCollection) =>
dbContext.MultiCollections
.Include(mc => mc.MultiCollectionItems)
.Include(mc => mc.MultiCollectionSmartItems)
.SelectOneAsync(c => c.Id, c => c.Id == updateCollection.MultiCollectionId)
.Map(o => o.ToValidation<BaseError>("MultiCollection does not exist."));

View File

@@ -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>>;
}

View File

@@ -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."));
}
}

View File

@@ -13,7 +13,11 @@ namespace ErsatzTV.Application.MediaCollections
new(
multiCollection.Id,
multiCollection.Name,
Optional(multiCollection.MultiCollectionItems).Flatten().Map(ProjectToViewModel).ToList());
Optional(multiCollection.MultiCollectionItems).Flatten().Map(ProjectToViewModel).ToList(),
Optional(multiCollection.MultiCollectionSmartItems).Flatten().Map(ProjectToViewModel).ToList());
internal static SmartCollectionViewModel ProjectToViewModel(SmartCollection collection) =>
new(collection.Id, collection.Name, collection.Query);
private static MultiCollectionItemViewModel ProjectToViewModel(MultiCollectionItem multiCollectionItem) =>
new(
@@ -21,5 +25,12 @@ namespace ErsatzTV.Application.MediaCollections
ProjectToViewModel(multiCollectionItem.Collection),
multiCollectionItem.ScheduleAsGroup,
multiCollectionItem.PlaybackOrder);
private static MultiCollectionSmartItemViewModel ProjectToViewModel(MultiCollectionSmartItem multiCollectionSmartItem) =>
new(
multiCollectionSmartItem.MultiCollectionId,
ProjectToViewModel(multiCollectionSmartItem.SmartCollection),
multiCollectionSmartItem.ScheduleAsGroup,
multiCollectionSmartItem.PlaybackOrder);
}
}

View File

@@ -0,0 +1,10 @@
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.MediaCollections
{
public record MultiCollectionSmartItemViewModel(
int MultiCollectionId,
SmartCollectionViewModel SmartCollection,
bool ScheduleAsGroup,
PlaybackOrder PlaybackOrder);
}

View File

@@ -2,5 +2,9 @@
namespace ErsatzTV.Application.MediaCollections
{
public record MultiCollectionViewModel(int Id, string Name, List<MultiCollectionItemViewModel> Items);
public record MultiCollectionViewModel(
int Id,
string Name,
List<MultiCollectionItemViewModel> Items,
List<MultiCollectionSmartItemViewModel> SmartItems);
}

View File

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

View File

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

View File

@@ -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());
}
}
}

View File

@@ -24,6 +24,8 @@ namespace ErsatzTV.Application.MediaCollections.Queries
return await dbContext.MultiCollections
.Include(mc => mc.MultiCollectionItems)
.ThenInclude(mc => mc.Collection)
.Include(mc => mc.MultiCollectionSmartItems)
.ThenInclude(mc => mc.SmartCollection)
.SelectOneAsync(c => c.Id, c => c.Id == request.Id)
.MapT(ProjectToViewModel);
}

View File

@@ -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(

View File

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

View File

@@ -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);
}
}
}

View File

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

View File

@@ -78,7 +78,7 @@ namespace ErsatzTV.Application.MediaSources.Commands
decimal progressMin = (decimal) i / localLibrary.Paths.Count;
decimal progressMax = (decimal) (i + 1) / localLibrary.Paths.Count;
var lastScan = new DateTimeOffset(libraryPath.LastScan ?? DateTime.MinValue, TimeSpan.Zero);
var lastScan = new DateTimeOffset(libraryPath.LastScan ?? SystemTime.MinValueUtc, TimeSpan.Zero);
DateTimeOffset nextScan = lastScan + TimeSpan.FromHours(libraryRefreshInterval);
if (forceScan || nextScan < DateTimeOffset.Now)
{

View File

@@ -65,7 +65,7 @@ namespace ErsatzTV.Application.Plex.Commands
private async Task<Unit> Synchronize(RequestParameters parameters)
{
var lastScan = new DateTimeOffset(parameters.Library.LastScan ?? DateTime.MinValue, TimeSpan.Zero);
var lastScan = new DateTimeOffset(parameters.Library.LastScan ?? SystemTime.MinValueUtc, TimeSpan.Zero);
DateTimeOffset nextScan = lastScan + TimeSpan.FromHours(parameters.LibraryRefreshInterval);
if (parameters.ForceScan || nextScan < DateTimeOffset.Now)
{

View File

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

View File

@@ -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; }

View File

@@ -24,6 +24,19 @@ namespace ErsatzTV.Application.ProgramSchedules.Commands
IProgramScheduleItemRequest item,
ProgramSchedule programSchedule)
{
if (item.MultiCollectionId.HasValue)
{
switch (item.PlaybackOrder)
{
case PlaybackOrder.Chronological:
case PlaybackOrder.Random:
return BaseError.New($"Invalid playback order for multi collection: '{item.PlaybackOrder}'");
case PlaybackOrder.Shuffle:
case PlaybackOrder.ShuffleInOrder:
break;
}
}
switch (item.PlayoutMode)
{
case PlayoutMode.Flood:
@@ -95,6 +108,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");
@@ -117,6 +137,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
@@ -129,6 +150,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
@@ -141,6 +163,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(),
@@ -154,6 +177,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()),

View File

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

View File

@@ -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,8 +112,7 @@ namespace ErsatzTV.Application.ProgramSchedules.Commands
ProgramScheduleItemCollectionType CollectionType,
int? CollectionId,
int? MediaItemId,
int? MultiCollectionId);
private record CollectionKeyOrder(CollectionKey Key, PlaybackOrder PlaybackOrder);
int? MultiCollectionId,
int? SmartCollectionId);
}
}

View File

@@ -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
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
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
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),

View File

@@ -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
collectionType,
collection,
multiCollection,
smartCollection,
mediaItem,
playbackOrder,
customTitle)

View File

@@ -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
collectionType,
collection,
multiCollection,
smartCollection,
mediaItem,
playbackOrder,
customTitle)

View File

@@ -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
collectionType,
collection,
multiCollection,
smartCollection,
mediaItem,
playbackOrder,
customTitle) =>

View File

@@ -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
collectionType,
collection,
multiCollection,
smartCollection,
mediaItem,
playbackOrder,
customTitle)

View File

@@ -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
MediaItem?.Name,
ProgramScheduleItemCollectionType.MultiCollection =>
MultiCollection?.Name,
ProgramScheduleItemCollectionType.SmartCollection =>
SmartCollection?.Name,
_ => string.Empty
};
}

View File

@@ -28,6 +28,7 @@ namespace ErsatzTV.Application.ProgramSchedules.Queries
.Filter(psi => psi.ProgramScheduleId == request.Id)
.Include(i => i.Collection)
.Include(i => i.MultiCollection)
.Include(i => i.SmartCollection)
.Include(i => i.MediaItem)
.ThenInclude(i => (i as Movie).MovieMetadata)
.ThenInclude(mm => mm.Artwork)

View File

@@ -10,10 +10,10 @@
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="FluentAssertions" Version="5.10.3" />
<PackageReference Include="FluentAssertions" Version="6.1.0" />
<PackageReference Include="LanguageExt.Core" Version="3.4.15" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="5.0.2" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.10.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.11.0" />
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="16.10.56">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>

View File

@@ -4,6 +4,7 @@ using ErsatzTV.Core.FFmpeg;
using FluentAssertions;
using LanguageExt;
using NUnit.Framework;
using static LanguageExt.Prelude;
namespace ErsatzTV.Core.Tests.FFmpeg
{
@@ -113,6 +114,161 @@ namespace ErsatzTV.Core.Tests.FFmpeg
});
}
[Test]
[TestCase(
false,
false,
false,
ChannelWatermarkLocation.BottomLeft,
false,
100,
"[0:0][1:v]overlay=x=134:y=H-h-54[v]",
"0:1",
"[v]")]
[TestCase(
false,
false,
false,
ChannelWatermarkLocation.BottomRight,
false,
100,
"[0:0][1:v]overlay=x=W-w-134:y=H-h-54[v]",
"0:1",
"[v]")]
[TestCase(
false,
false,
false,
ChannelWatermarkLocation.TopLeft,
false,
100,
"[0:0][1:v]overlay=x=134:y=54[v]",
"0:1",
"[v]")]
[TestCase(
false,
false,
false,
ChannelWatermarkLocation.TopRight,
false,
100,
"[0:0][1:v]overlay=x=W-w-134:y=54[v]",
"0:1",
"[v]")]
[TestCase(
false,
false,
true,
ChannelWatermarkLocation.TopLeft,
false,
100,
"[0:0][1:v]overlay=x=134:y=54:enable='lt(mod(mod(time(0),60*60),10*60),15)'[v]",
"0:1",
"[v]")]
[TestCase(
false,
false,
false,
ChannelWatermarkLocation.TopLeft,
true,
100,
"[1:v]scale=384:-1[wmp];[0:0][wmp]overlay=x=134:y=54[v]",
"0:1",
"[v]")]
[TestCase(
false,
false,
false,
ChannelWatermarkLocation.TopLeft,
false,
90,
"[1:v]format=yuva420p|yuva444p|yuva422p|rgba|abgr|bgra|gbrap|ya8,colorchannelmixer=aa=0.90[wmp];[0:0][wmp]overlay=x=134:y=54[v]",
"0:1",
"[v]")]
[TestCase(
false,
true,
false,
ChannelWatermarkLocation.TopLeft,
false,
100,
"[0:0]yadif=1[vt];[vt][1:v]overlay=x=134:y=54[v]",
"0:1",
"[v]")]
[TestCase(
false,
true,
false,
ChannelWatermarkLocation.TopLeft,
true,
100,
"[0:0]yadif=1[vt];[1:v]scale=384:-1[wmp];[vt][wmp]overlay=x=134:y=54[v]",
"0:1",
"[v]")]
[TestCase(
true,
true,
false,
ChannelWatermarkLocation.TopLeft,
false,
100,
"[0:1]apad=whole_dur=3300000ms[a];[0:0]yadif=1[vt];[vt][1:v]overlay=x=134:y=54[v]",
"[a]",
"[v]")]
[TestCase(
true,
false,
false,
ChannelWatermarkLocation.TopLeft,
false,
100,
"[0:1]apad=whole_dur=3300000ms[a];[0:0][1:v]overlay=x=134:y=54[v]",
"[a]",
"[v]")]
public void Should_Return_Watermark(
bool alignAudio,
bool deinterlace,
bool intermittent,
ChannelWatermarkLocation location,
bool scaled,
int opacity,
string expectedVideoFilter,
string expectedAudioLabel,
string expectedVideoLabel)
{
FFmpegComplexFilterBuilder builder = new FFmpegComplexFilterBuilder()
.WithWatermark(
Some(
new ChannelWatermark
{
Mode = intermittent
? ChannelWatermarkMode.Intermittent
: ChannelWatermarkMode.Permanent,
DurationSeconds = intermittent ? 15 : 0,
FrequencyMinutes = intermittent ? 10 : 0,
Location = location,
Size = scaled ? ChannelWatermarkSize.Scaled : ChannelWatermarkSize.ActualSize,
WidthPercent = scaled ? 20 : 0,
Opacity = opacity,
HorizontalMarginPercent = 7,
VerticalMarginPercent = 5
}),
new Resolution { Width = 1920, Height = 1080 })
.WithDeinterlace(deinterlace)
.WithAlignedAudio(alignAudio ? Some(TimeSpan.FromMinutes(55)) : None);
Option<FFmpegComplexFilter> result = builder.Build(0, 1);
result.IsSome.Should().BeTrue();
result.IfSome(
filter =>
{
filter.ComplexFilter.Should().Be(expectedVideoFilter);
filter.AudioLabel.Should().Be(expectedAudioLabel);
filter.VideoLabel.Should().Be(expectedVideoLabel);
});
}
[Test]
[TestCase(true, false, false, "[0:0]deinterlace_qsv[v]", "[v]")]
[TestCase(

View File

@@ -4,6 +4,6 @@ namespace ErsatzTV.Core.Tests.Fakes
{
public record FakeFileEntry(string Path)
{
public DateTime LastWriteTime { get; set; } = DateTime.MinValue;
public DateTime LastWriteTime { get; set; } = SystemTime.MinValueUtc;
}
}

View File

@@ -41,7 +41,7 @@ namespace ErsatzTV.Core.Tests.Fakes
public DateTime GetLastWriteTime(string path) =>
Optional(_files.SingleOrDefault(f => f.Path == path))
.Map(f => f.LastWriteTime)
.IfNone(DateTime.MinValue);
.IfNone(SystemTime.MinValueUtc);
public bool IsLibraryPathAccessible(LibraryPath libraryPath) =>
_files.Any(f => f.Path.StartsWith(libraryPath.Path + Path.DirectorySeparatorChar));

View File

@@ -20,15 +20,22 @@ 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();
public Task<List<CollectionWithItems>> GetFakeMultiCollectionCollections(int? collectionId, int? smartCollectionId) =>
throw new NotSupportedException();
public Task<List<int>> PlayoutIdsUsingCollection(int collectionId) => throw new NotSupportedException();
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();
}
}

View File

@@ -91,7 +91,7 @@ namespace ErsatzTV.Core.Tests.Metadata
});
metadata.Count.Should().Be(2);
metadata.Map(m => m.EpisodeNumber).Should().BeEquivalentTo(episode1, episode2);
metadata.Map(m => m.EpisodeNumber).Should().BeEquivalentTo(new[] { episode1, episode2 });
}
[Test]

View File

@@ -0,0 +1,36 @@
using System;
using System.Collections.Generic;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Metadata;
using FluentAssertions;
using Microsoft.Extensions.Logging;
using Moq;
using NUnit.Framework;
namespace ErsatzTV.Core.Tests.Metadata
{
[TestFixture]
public class LocalStatisticsProviderTests
{
[Test]
// this needs to be a culture where '.' is a group separator
[SetCulture("it-IT")]
public void Test()
{
var provider = new LocalStatisticsProvider(
new Mock<IMetadataRepository>().Object,
new Mock<ILocalFileSystem>().Object,
new Mock<ILogger<LocalStatisticsProvider>>().Object);
var input = new LocalStatisticsProvider.FFprobe(
new LocalStatisticsProvider.FFprobeFormat("123.45"),
new List<LocalStatisticsProvider.FFprobeStream>());
MediaVersion result = provider.ProjectToMediaVersion("test", input);
result.Duration.Should().Be(TimeSpan.FromSeconds(123.45));
}
}
}

View File

@@ -405,6 +405,48 @@ namespace ErsatzTV.Core.Tests.Metadata
It.Is<Movie>(i => i.MediaVersions.Head().MediaFiles.Head().Path == moviePath)),
Times.Once);
}
[Test]
public async Task Should_Ignore_Dot_Underscore_Files(
[ValueSource(typeof(LocalFolderScanner), nameof(LocalFolderScanner.VideoFileExtensions))]
string videoExtension)
{
string moviePath = Path.Combine(
FakeRoot,
Path.Combine("Movie (2020)", $"Movie (2020){videoExtension}"));
MovieFolderScanner service = GetService(
new FakeFileEntry(moviePath) { LastWriteTime = DateTime.Now },
new FakeFileEntry(
Path.Combine(
Path.GetDirectoryName(moviePath) ?? string.Empty,
$"._Movie (2020){videoExtension}"))
);
var libraryPath = new LibraryPath
{ Id = 1, Path = FakeRoot, LibraryFolders = new List<LibraryFolder>() };
Either<BaseError, Unit> result = await service.ScanFolder(
libraryPath,
FFprobePath,
0,
1);
result.IsRight.Should().BeTrue();
_movieRepository.Verify(x => x.GetOrAdd(It.IsAny<LibraryPath>(), It.IsAny<string>()), Times.Once);
_movieRepository.Verify(x => x.GetOrAdd(libraryPath, moviePath), Times.Once);
_localStatisticsProvider.Verify(
x => x.RefreshStatistics(
FFprobePath,
It.Is<Movie>(i => i.MediaVersions.Head().MediaFiles.Head().Path == moviePath)),
Times.Once);
_localMetadataProvider.Verify(
x => x.RefreshFallbackMetadata(
It.Is<Movie>(i => i.MediaVersions.Head().MediaFiles.Head().Path == moviePath)),
Times.Once);
}
[Test]
public async Task Should_Ignore_Extra_Folders(

View File

@@ -84,7 +84,7 @@ namespace ErsatzTV.Core.Tests.Scheduling
}
list.Should().NotEqual(new[] { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 });
list.Should().BeEquivalentTo(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);
list.Should().BeEquivalentTo(new[] { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 });
}
[Test]

View File

@@ -7,6 +7,8 @@ namespace ErsatzTV.Core.Domain
public int Id { get; set; }
public string Name { get; set; }
public List<Collection> Collections { get; set; }
public List<SmartCollection> SmartCollections { get; set; }
public List<MultiCollectionItem> MultiCollectionItems { get; set; }
public List<MultiCollectionSmartItem> MultiCollectionSmartItems { get; set; }
}
}

View File

@@ -0,0 +1,12 @@
namespace ErsatzTV.Core.Domain
{
public class MultiCollectionSmartItem
{
public int MultiCollectionId { get; set; }
public MultiCollection MultiCollection { get; set; }
public int SmartCollectionId { get; set; }
public SmartCollection SmartCollection { get; set; }
public bool ScheduleAsGroup { get; set; }
public PlaybackOrder PlaybackOrder { get; set; }
}
}

View File

@@ -0,0 +1,13 @@
using System.Collections.Generic;
namespace ErsatzTV.Core.Domain
{
public class SmartCollection
{
public int Id { get; set; }
public string Name { get; set; }
public string Query { get; set; }
public List<MultiCollection> MultiCollections { get; set; }
public List<MultiCollectionSmartItem> MultiCollectionSmartItems { get; set; }
}
}

View File

@@ -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");

View File

@@ -4,6 +4,7 @@
{
Chronological = 1,
Random = 2,
Shuffle = 3
Shuffle = 3,
ShuffleInOrder = 4
}
}

View File

@@ -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; }

View File

@@ -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; }
}
}

View File

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

View File

@@ -24,5 +24,11 @@
<PackageReference Include="Serilog" Version="2.10.0" />
<PackageReference Include="Serilog.Sinks.Console" Version="4.0.0" />
</ItemGroup>
<ItemGroup>
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleToAttribute">
<_Parameter1>ErsatzTV.Core.Tests</_Parameter1>
</AssemblyAttribute>
</ItemGroup>
</Project>

View File

@@ -233,28 +233,39 @@ namespace ErsatzTV.Core.FFmpeg
complexFilter.Append(audioLabel);
}
if (videoFilterQueue.Any())
if (videoFilterQueue.Any() || !string.IsNullOrWhiteSpace(watermarkOverlay))
{
if (hasAudioFilters)
{
complexFilter.Append(';');
}
complexFilter.Append($"[{videoLabel}]");
var filters = string.Join(",", videoFilterQueue);
complexFilter.Append(filters);
if (videoFilterQueue.Any())
{
complexFilter.Append($"[{videoLabel}]");
var filters = string.Join(",", videoFilterQueue);
complexFilter.Append(filters);
}
if (!string.IsNullOrWhiteSpace(watermarkOverlay))
{
complexFilter.Append("[vt]");
if (videoFilterQueue.Any())
{
complexFilter.Append("[vt];");
}
var watermarkLabel = "[1:v]";
if (!string.IsNullOrWhiteSpace(watermarkPreprocess))
{
complexFilter.Append($";{watermarkLabel}{watermarkPreprocess}[wmp]");
complexFilter.Append($"{watermarkLabel}{watermarkPreprocess}[wmp];");
watermarkLabel = "[wmp]";
}
complexFilter.Append($";[vt]{watermarkLabel}{watermarkOverlay}");
complexFilter.Append(
videoFilterQueue.Any()
? $"[vt]{watermarkLabel}{watermarkOverlay}"
: $"[{videoLabel}]{watermarkLabel}{watermarkOverlay}");
if (usesSoftwareFilters && acceleration != HardwareAccelerationKind.None)
{
complexFilter.Append(",hwupload");

View File

@@ -11,9 +11,12 @@ 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<CollectionWithItems>> GetFakeMultiCollectionCollections(int? collectionId, int? smartCollectionId);
Task<List<int>> PlayoutIdsUsingCollection(int collectionId);
Task<List<int>> PlayoutIdsUsingMultiCollection(int multiCollectionId);
Task<List<int>> PlayoutIdsUsingSmartCollection(int smartCollectionId);
Task<bool> IsCustomPlaybackOrder(int collectionId);
}
}

View File

@@ -36,32 +36,39 @@ namespace ErsatzTV.Core.Iptv
xml.WriteStartElement("tv");
xml.WriteAttributeString("generator-info-name", "ersatztv");
var sortedChannelItems = new Dictionary<Channel, List<PlayoutItem>>();
foreach (Channel channel in _channels.OrderBy(c => decimal.Parse(c.Number)))
{
xml.WriteStartElement("channel");
xml.WriteAttributeString("id", channel.Number);
var sortedItems = channel.Playouts.Collect(p => p.Items).OrderBy(x => x.Start).ToList();
sortedChannelItems.Add(channel, sortedItems);
xml.WriteStartElement("display-name");
xml.WriteAttributeString("lang", "en");
xml.WriteString(channel.Name);
xml.WriteEndElement(); // display-name
if (sortedItems.Any())
{
xml.WriteStartElement("channel");
xml.WriteAttributeString("id", $"{channel.Number}.etv");
xml.WriteStartElement("icon");
string logo = Optional(channel.Artwork).Flatten()
.Filter(a => a.ArtworkKind == ArtworkKind.Logo)
.HeadOrNone()
.Match(
artwork => $"{_scheme}://{_host}/iptv/logos/{artwork.Path}",
() => $"{_scheme}://{_host}/iptv/images/ersatztv-500.png");
xml.WriteAttributeString("src", logo);
xml.WriteEndElement(); // icon
xml.WriteStartElement("display-name");
xml.WriteAttributeString("lang", "en");
xml.WriteString(channel.Name);
xml.WriteEndElement(); // display-name
xml.WriteEndElement(); // channel
xml.WriteStartElement("icon");
string logo = Optional(channel.Artwork).Flatten()
.Filter(a => a.ArtworkKind == ArtworkKind.Logo)
.HeadOrNone()
.Match(
artwork => $"{_scheme}://{_host}/iptv/logos/{artwork.Path}.jpg",
() => $"{_scheme}://{_host}/iptv/images/ersatztv-500.png");
xml.WriteAttributeString("src", logo);
xml.WriteEndElement(); // icon
xml.WriteEndElement(); // channel
}
}
foreach (Channel channel in _channels.OrderBy(c => c.Number))
foreach ((Channel channel, List<PlayoutItem> sorted) in sortedChannelItems.OrderBy(kvp => kvp.Key.Number))
{
var sorted = channel.Playouts.Collect(p => p.Items).OrderBy(x => x.Start).ToList();
var i = 0;
while (i < sorted.Count)
{
@@ -90,7 +97,9 @@ namespace ErsatzTV.Core.Iptv
PlayoutItem finishItem = sorted[finishIndex];
i = finishIndex;
// ReSharper disable once StringLiteralTypo
string start = startItem.StartOffset.ToString("yyyyMMddHHmmss zzz").Replace(":", string.Empty);
// ReSharper disable once StringLiteralTypo
string stop = finishItem.FinishOffset.ToString("yyyyMMddHHmmss zzz").Replace(":", string.Empty);
string title = GetTitle(startItem);
@@ -101,27 +110,51 @@ namespace ErsatzTV.Core.Iptv
xml.WriteStartElement("programme");
xml.WriteAttributeString("start", start);
xml.WriteAttributeString("stop", stop);
xml.WriteAttributeString("channel", channel.Number);
xml.WriteAttributeString("channel", $"{channel.Number}.etv");
xml.WriteStartElement("title");
xml.WriteAttributeString("lang", "en");
xml.WriteString(title);
xml.WriteEndElement(); // title
if (!string.IsNullOrWhiteSpace(subtitle))
{
xml.WriteStartElement("sub-title");
xml.WriteAttributeString("lang", "en");
xml.WriteString(subtitle);
xml.WriteEndElement(); // subtitle
}
if (!isSameCustomShow)
{
if (!string.IsNullOrWhiteSpace(description))
{
xml.WriteStartElement("desc");
xml.WriteAttributeString("lang", "en");
xml.WriteString(description);
xml.WriteEndElement(); // desc
}
}
if (!hasCustomTitle && startItem.MediaItem is Movie movie)
{
xml.WriteStartElement("category");
xml.WriteAttributeString("lang", "en");
xml.WriteString("Movie");
xml.WriteEndElement(); // category
Option<MovieMetadata> maybeMetadata = movie.MovieMetadata.HeadOrNone();
if (maybeMetadata.IsSome)
foreach (MovieMetadata metadata in movie.MovieMetadata.HeadOrNone())
{
MovieMetadata metadata = maybeMetadata.ValueUnsafe();
if (metadata.Year.HasValue)
{
xml.WriteStartElement("date");
xml.WriteString(metadata.Year.Value.ToString());
xml.WriteEndElement(); // date
}
}
xml.WriteStartElement("category");
xml.WriteAttributeString("lang", "en");
xml.WriteString("Movie");
xml.WriteEndElement(); // category
foreach (MovieMetadata metadata in movie.MovieMetadata.HeadOrNone())
{
string poster = Optional(metadata.Artwork).Flatten()
.Filter(a => a.ArtworkKind == ArtworkKind.Poster)
.HeadOrNone()
@@ -136,22 +169,6 @@ namespace ErsatzTV.Core.Iptv
}
}
xml.WriteStartElement("title");
xml.WriteAttributeString("lang", "en");
xml.WriteString(title);
xml.WriteEndElement(); // title
if (!string.IsNullOrWhiteSpace(subtitle))
{
xml.WriteStartElement("sub-title");
xml.WriteAttributeString("lang", "en");
xml.WriteString(subtitle);
xml.WriteEndElement(); // subtitle
}
xml.WriteStartElement("previously-shown");
xml.WriteEndElement(); // previously-shown
if (startItem.MediaItem is Episode episode && (!hasCustomTitle || isSameCustomShow))
{
Option<ShowMetadata> maybeMetadata =
@@ -200,17 +217,9 @@ namespace ErsatzTV.Core.Iptv
}
}
}
if (!isSameCustomShow)
{
if (!string.IsNullOrWhiteSpace(description))
{
xml.WriteStartElement("desc");
xml.WriteAttributeString("lang", "en");
xml.WriteString(description);
xml.WriteEndElement(); // desc
}
}
xml.WriteStartElement("previously-shown");
xml.WriteEndElement(); // previously-shown
foreach (ContentRating rating in contentRating)
{
@@ -267,7 +276,7 @@ namespace ErsatzTV.Core.Iptv
_ => "posters"
};
artworkPath = $"{_scheme}://{_host}/iptv/artwork/{artworkFolder}/{artwork.Path}";
artworkPath = $"{_scheme}://{_host}/iptv/artwork/{artworkFolder}/{artwork.Path}.jpg";
}
return artworkPath;

View File

@@ -32,7 +32,7 @@ namespace ErsatzTV.Core.Iptv
.Filter(a => a.ArtworkKind == ArtworkKind.Logo)
.HeadOrNone()
.Match(
artwork => $"{_scheme}://{_host}/iptv/logos/{artwork.Path}",
artwork => $"{_scheme}://{_host}/iptv/logos/{artwork.Path}.jpg",
() => $"{_scheme}://{_host}/iptv/images/ersatztv-500.png");
string shortUniqueId = Convert.ToBase64String(channel.UniqueId.ToByteArray())
@@ -51,7 +51,7 @@ namespace ErsatzTV.Core.Iptv
string acodec = channel.FFmpegProfile.AudioCodec;
sb.AppendLine(
$"#EXTINF:0 tvg-id=\"{channel.Number}\" channel-id=\"{shortUniqueId}\" channel-number=\"{channel.Number}\" CUID=\"{shortUniqueId}\" tvg-chno=\"{channel.Number}\" tvg-name=\"{channel.Name}\" tvg-logo=\"{logo}\" group-title=\"ErsatzTV\" tvc-stream-vcodec=\"{vcodec}\" tvc-stream-acodec=\"{acodec}\", {channel.Name}");
$"#EXTINF:0 tvg-id=\"{channel.Number}.etv\" channel-id=\"{shortUniqueId}\" channel-number=\"{channel.Number}\" CUID=\"{shortUniqueId}\" tvg-chno=\"{channel.Number}\" tvg-name=\"{channel.Name}\" tvg-logo=\"{logo}\" group-title=\"ErsatzTV\" tvc-stream-vcodec=\"{vcodec}\" tvc-stream-acodec=\"{acodec}\", {channel.Name}");
sb.AppendLine($"{_scheme}://{_host}/iptv/channel/{channel.Number}.{format}");
}

View File

@@ -22,7 +22,7 @@ namespace ErsatzTV.Core.Metadata
}
public DateTime GetLastWriteTime(string path) =>
Try(File.GetLastWriteTimeUtc(path)).IfFail(() => DateTime.MinValue);
Try(File.GetLastWriteTimeUtc(path)).IfFail(() => SystemTime.MinValueUtc);
public bool IsLibraryPathAccessible(LibraryPath libraryPath) =>
Directory.Exists(libraryPath.Path);

View File

@@ -218,7 +218,7 @@ namespace ErsatzTV.Core.Metadata
existing.Tagline = metadata.Tagline;
existing.Title = metadata.Title;
if (existing.DateAdded == DateTime.MinValue)
if (existing.DateAdded == SystemTime.MinValueUtc)
{
existing.DateAdded = metadata.DateAdded;
}
@@ -318,7 +318,7 @@ namespace ErsatzTV.Core.Metadata
existing.Tagline = metadata.Tagline;
existing.Title = metadata.Title;
if (existing.DateAdded == DateTime.MinValue)
if (existing.DateAdded == SystemTime.MinValueUtc)
{
existing.DateAdded = metadata.DateAdded;
}
@@ -423,7 +423,7 @@ namespace ErsatzTV.Core.Metadata
existing.Tagline = metadata.Tagline;
existing.Title = metadata.Title;
if (existing.DateAdded == DateTime.MinValue)
if (existing.DateAdded == SystemTime.MinValueUtc)
{
existing.DateAdded = metadata.DateAdded;
}
@@ -486,7 +486,7 @@ namespace ErsatzTV.Core.Metadata
existing.Disambiguation = metadata.Disambiguation;
existing.Biography = metadata.Biography;
if (existing.DateAdded == DateTime.MinValue)
if (existing.DateAdded == SystemTime.MinValueUtc)
{
existing.DateAdded = metadata.DateAdded;
}
@@ -581,7 +581,7 @@ namespace ErsatzTV.Core.Metadata
existing.Plot = metadata.Plot;
existing.Album = metadata.Album;
if (existing.DateAdded == DateTime.MinValue)
if (existing.DateAdded == SystemTime.MinValueUtc)
{
existing.DateAdded = metadata.DateAdded;
}

View File

@@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Globalization;
using System.Linq;
using System.Threading.Tasks;
using ErsatzTV.Core.Domain;
@@ -126,7 +127,7 @@ namespace ErsatzTV.Core.Metadata
});
}
private MediaVersion ProjectToMediaVersion(string path, FFprobe probeOutput) =>
internal MediaVersion ProjectToMediaVersion(string path, FFprobe probeOutput) =>
Optional(probeOutput)
.Filter(json => json?.format != null && json.streams != null)
.ToValidation<BaseError>("Unable to parse ffprobe output")
@@ -137,7 +138,7 @@ namespace ErsatzTV.Core.Metadata
var version = new MediaVersion
{ Name = "Main", DateAdded = DateTime.UtcNow, Streams = new List<MediaStream>() };
if (double.TryParse(json.format.duration, out double duration))
if (double.TryParse(json.format.duration, NumberStyles.Number, CultureInfo.InvariantCulture, out double duration))
{
var seconds = TimeSpan.FromSeconds(duration);
version.Duration = seconds;

View File

@@ -87,6 +87,7 @@ namespace ErsatzTV.Core.Metadata
var allFiles = filesForEtag
.Filter(f => VideoFileExtensions.Contains(Path.GetExtension(f)))
.Filter(f => !Path.GetFileName(f).StartsWith("._"))
.Filter(
f => !ExtraFiles.Any(
e => Path.GetFileNameWithoutExtension(f).EndsWith(e, StringComparison.OrdinalIgnoreCase)))
@@ -157,6 +158,12 @@ namespace ErsatzTV.Core.Metadata
List<int> ids = await _movieRepository.DeleteByPath(libraryPath, path);
await _searchIndex.RemoveItems(ids);
}
else if (Path.GetFileName(path).StartsWith("._"))
{
_logger.LogInformation("Removing dot underscore file at {Path}", path);
List<int> ids = await _movieRepository.DeleteByPath(libraryPath, path);
await _searchIndex.RemoveItems(ids);
}
}
_searchIndex.Commit();

View File

@@ -134,6 +134,12 @@ namespace ErsatzTV.Core.Metadata
List<int> musicVideoIds = await _musicVideoRepository.DeleteByPath(libraryPath, path);
await _searchIndex.RemoveItems(musicVideoIds);
}
else if (Path.GetFileName(path).StartsWith("._"))
{
_logger.LogInformation("Removing dot underscore file at {Path}", path);
List<int> musicVideoIds = await _musicVideoRepository.DeleteByPath(libraryPath, path);
await _searchIndex.RemoveItems(musicVideoIds);
}
}
List<int> artistIds = await _artistRepository.DeleteEmptyArtists(libraryPath);
@@ -238,6 +244,7 @@ namespace ErsatzTV.Core.Metadata
var allFiles = _localFileSystem.ListFiles(musicVideoFolder)
.Filter(f => VideoFileExtensions.Contains(Path.GetExtension(f)))
.Filter(f => !Path.GetFileName(f).StartsWith("._"))
.ToList();
foreach (string subdirectory in _localFileSystem.ListSubdirectories(musicVideoFolder)

View File

@@ -124,6 +124,11 @@ namespace ErsatzTV.Core.Metadata
_logger.LogInformation("Removing missing episode at {Path}", path);
await _televisionRepository.DeleteByPath(libraryPath, path);
}
else if (Path.GetFileName(path).StartsWith("._"))
{
_logger.LogInformation("Removing dot underscore file at {Path}", path);
await _televisionRepository.DeleteByPath(libraryPath, path);
}
}
await _televisionRepository.DeleteEmptySeasons(libraryPath);
@@ -201,7 +206,9 @@ namespace ErsatzTV.Core.Metadata
string seasonPath)
{
foreach (string file in _localFileSystem.ListFiles(seasonPath)
.Filter(f => VideoFileExtensions.Contains(Path.GetExtension(f))).OrderBy(identity))
.Filter(f => VideoFileExtensions.Contains(Path.GetExtension(f)))
.Filter(f => !Path.GetFileName(f).StartsWith("._"))
.OrderBy(identity))
{
// TODO: figure out how to rebuild playlists
Either<BaseError, Episode> maybeEpisode = await _televisionRepository
@@ -315,7 +322,7 @@ namespace ErsatzTV.Core.Metadata
async () =>
{
bool shouldUpdate = Optional(episode.EpisodeMetadata).Flatten().HeadOrNone().Match(
m => m.DateUpdated == DateTime.MinValue,
m => m.DateUpdated == SystemTime.MinValueUtc,
true);
if (shouldUpdate)

View File

@@ -66,13 +66,17 @@ namespace ErsatzTV.Core.Scheduling
int episode1 = x switch
{
Episode e => e.EpisodeMetadata.Max(em => em.EpisodeNumber),
Episode e => e.EpisodeMetadata.HeadOrNone().Match(
em => em.EpisodeNumber,
() => int.MaxValue),
_ => int.MaxValue
};
int episode2 = y switch
{
Episode e => e.EpisodeMetadata.Max(em => em.EpisodeNumber),
Episode e => e.EpisodeMetadata.HeadOrNone().Match(
em => em.EpisodeNumber,
() => int.MaxValue),
_ => int.MaxValue
};

View File

@@ -121,32 +121,35 @@ namespace ErsatzTV.Core.Scheduling
private static Option<int> FindPartNumber(Episode e)
{
const string PATTERN = @"^.*\((\d+)\)( - .*)?$";
Match match = Regex.Match(e.EpisodeMetadata.Head().Title, PATTERN);
if (match.Success && int.TryParse(match.Groups[1].Value, out int value1))
foreach (EpisodeMetadata metadata in e.EpisodeMetadata.HeadOrNone())
{
return value1;
}
const string PATTERN = @"^.*\((\d+)\)( - .*)?$";
Match match = Regex.Match(metadata.Title ?? string.Empty, PATTERN);
if (match.Success && int.TryParse(match.Groups[1].Value, out int value1))
{
return value1;
}
const string PATTERN_2 = @"^.*\(?Part (\d+)\)?$";
Match match2 = Regex.Match(e.EpisodeMetadata.Head().Title, PATTERN_2);
if (match2.Success && int.TryParse(match2.Groups[1].Value, out int value2))
{
return value2;
}
const string PATTERN_2 = @"^.*\(?Part (\d+)\)?$";
Match match2 = Regex.Match(metadata.Title ?? string.Empty, PATTERN_2);
if (match2.Success && int.TryParse(match2.Groups[1].Value, out int value2))
{
return value2;
}
const string PATTERN_3 = @"^.*\(([MDCLXVI]+)\)( - .*)?$";
Match match3 = Regex.Match(e.EpisodeMetadata.Head().Title, PATTERN_3);
if (match3.Success && TryParseRoman(match3.Groups[1].Value, out int value3))
{
return value3;
}
const string PATTERN_3 = @"^.*\(([MDCLXVI]+)\)( - .*)?$";
Match match3 = Regex.Match(metadata.Title ?? string.Empty, PATTERN_3);
if (match3.Success && TryParseRoman(match3.Groups[1].Value, out int value3))
{
return value3;
}
const string PATTERN_4 = @"^.*Part (\w+)$";
Match match4 = Regex.Match(e.EpisodeMetadata.Head().Title, PATTERN_4);
if (match4.Success && TryParseEnglish(match4.Groups[1].Value, out int value4))
{
return value4;
const string PATTERN_4 = @"^.*Part (\w+)$";
Match match4 = Regex.Match(metadata.Title ?? string.Empty, PATTERN_4);
if (match4.Success && TryParseEnglish(match4.Groups[1].Value, out int value4))
{
return value4;
}
}
return None;

View File

@@ -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>());
}
@@ -369,9 +374,9 @@ namespace ErsatzTV.Core.Scheduling
}
bool willNotFinishInTime =
currentTime <= durationFinish.IfNone(DateTime.MinValue) &&
currentTime <= durationFinish.IfNone(SystemTime.MinValueUtc) &&
currentTime + peekVersion.Duration >
durationFinish.IfNone(DateTime.MinValue);
durationFinish.IfNone(SystemTime.MinValueUtc);
if (willNotFinishInTime)
{
_logger.LogDebug(
@@ -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
&& a.CollectionType == collectionKey.CollectionType
&& a.CollectionId == collectionKey.CollectionId
&& a.MultiCollectionId == collectionKey.MultiCollectionId
&& a.SmartCollectionId == collectionKey.SmartCollectionId
&& a.MediaItemId == collectionKey.MediaItemId);
CollectionEnumeratorState state = maybeAnchor.Match(
@@ -567,6 +574,10 @@ namespace ErsatzTV.Core.Scheduling
return new ShuffledMediaCollectionEnumerator(
await GetGroupedMediaItemsForShuffle(playout, mediaItems, collectionKey),
state);
case PlaybackOrder.ShuffleInOrder:
return new ShuffleInOrderCollectionEnumerator(
await GetCollectionItemsForShuffleInOrder(collectionKey),
state);
default:
// TODO: handle this error case differently?
return new RandomizedMediaCollectionEnumerator(mediaItems, state);
@@ -593,6 +604,25 @@ namespace ErsatzTV.Core.Scheduling
: mediaItems.Map(mi => new GroupedMediaItem(mi, null)).ToList();
}
private async Task<List<CollectionWithItems>> GetCollectionItemsForShuffleInOrder(CollectionKey collectionKey)
{
var result = new List<CollectionWithItems>();
if (collectionKey.MultiCollectionId != null)
{
result = await _mediaCollectionRepository.GetMultiCollectionCollections(
collectionKey.MultiCollectionId.Value);
}
else
{
result = await _mediaCollectionRepository.GetFakeMultiCollectionCollections(
collectionKey.CollectionId,
collectionKey.SmartCollectionId);
}
return result;
}
private static string DisplayTitle(MediaItem mediaItem)
{
switch (mediaItem)
@@ -644,6 +674,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))
};
@@ -652,6 +687,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; }
}
}

View File

@@ -0,0 +1,190 @@
using System;
using System.Collections.Generic;
using System.Linq;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Scheduling;
using LanguageExt;
using static LanguageExt.Prelude;
namespace ErsatzTV.Core.Scheduling
{
public class ShuffleInOrderCollectionEnumerator : IMediaCollectionEnumerator
{
private readonly IList<CollectionWithItems> _collections;
private readonly int _mediaItemCount;
private Random _random;
private IList<MediaItem> _shuffled;
public ShuffleInOrderCollectionEnumerator(
IList<CollectionWithItems> collections,
CollectionEnumeratorState state)
{
_collections = collections;
_mediaItemCount = collections.Sum(c => c.MediaItems.Count);
if (state.Index >= _mediaItemCount)
{
state.Index = 0;
state.Seed = new Random(state.Seed).Next();
}
_random = new Random(state.Seed);
_shuffled = Shuffle(_collections, _random);
State = new CollectionEnumeratorState { Seed = state.Seed };
while (State.Index < state.Index)
{
MoveNext();
}
}
public CollectionEnumeratorState State { get; }
public Option<MediaItem> Current => _shuffled.Any() ? _shuffled[State.Index % _mediaItemCount] : None;
public void MoveNext()
{
if ((State.Index + 1) % _shuffled.Count == 0)
{
Option<MediaItem> tail = Current;
State.Index = 0;
do
{
State.Seed = _random.Next();
_random = new Random(State.Seed);
_shuffled = Shuffle(_collections, _random);
} while (_collections.Count > 1 && Current == tail);
}
else
{
State.Index++;
}
State.Index %= _shuffled.Count;
}
private IList<MediaItem> Shuffle(IList<CollectionWithItems> collections, Random random)
{
// based on https://keyj.emphy.de/balanced-shuffle/
var orderedCollections = collections
.Filter(c => c.ScheduleAsGroup)
.Map(c => new OrderedCollection { Index = 0, Items = OrderItems(c) })
.ToList();
if (collections.Any(c => !c.ScheduleAsGroup))
{
orderedCollections.Add(
new OrderedCollection
{
Index = 0,
Items = Shuffle(
collections.Filter(c => !c.ScheduleAsGroup).SelectMany(c => c.MediaItems.Map(Some)),
random)
});
}
List<OrderedCollection> filled = Fill(orderedCollections, random);
var result = new List<MediaItem>();
for (var i = 0; i < filled[0].Items.Count; i++)
{
var batch = filled.Select(collection => collection.Items[i]).ToList();
foreach (Option<MediaItem> maybeItem in Shuffle(batch, random))
{
result.AddRange(maybeItem);
}
}
return result;
}
private List<OrderedCollection> Fill(List<OrderedCollection> orderedCollections, Random random)
{
var result = new List<OrderedCollection>();
int maxLength = orderedCollections.Max(c => c.Items.Count);
foreach (OrderedCollection collection in orderedCollections)
{
var items = new Queue<Option<MediaItem>>(collection.Items);
var spaces = new Queue<Option<MediaItem>>(
Range(0, maxLength - collection.Items.Count).Map(_ => Option<MediaItem>.None).ToList());
Queue<Option<MediaItem>> smaller = collection.Items.Count < maxLength - collection.Items.Count
? items
: spaces;
Queue<Option<MediaItem>> larger = collection.Items.Count < maxLength - collection.Items.Count
? spaces
: items;
var ordered = new List<Option<MediaItem>>();
int k = smaller.Count;
while (k > 0)
{
int n = maxLength - ordered.Count;
// compute optimal length +/- 10%
double optimalLength = n / (double)k + (random.NextDouble() - 0.5) / 5.0;
int r = Math.Clamp((int)optimalLength, 1, maxLength - k + 1);
ordered.Add(smaller.Dequeue());
for (var i = 0; i < r - 1; i++)
{
ordered.Add(larger.Dequeue());
}
k--;
}
if (smaller.Any())
{
ordered.AddRange(smaller);
}
if (larger.Any())
{
ordered.AddRange(larger);
}
result.Add(new OrderedCollection { Index = 0, Items = ordered });
}
return result;
}
private static IList<Option<MediaItem>> OrderItems(CollectionWithItems collectionWithItems)
{
if (collectionWithItems.UseCustomOrder)
{
return collectionWithItems.MediaItems.Map(Some).ToList();
}
return collectionWithItems.MediaItems
.OrderBy(identity, new ChronologicalMediaComparer())
.Map(Some)
.ToList();
}
private static IList<Option<MediaItem>> Shuffle(IEnumerable<Option<MediaItem>> list, Random random)
{
Option<MediaItem>[] copy = list.ToArray();
int n = copy.Length;
while (n > 1)
{
n--;
int k = random.Next(n + 1);
(copy[k], copy[n]) = (copy[n], copy[k]);
}
return copy;
}
private class OrderedCollection
{
public int Index { get; set; }
public IList<Option<MediaItem>> Items { get; set; }
}
}
}

View File

@@ -73,9 +73,7 @@ namespace ErsatzTV.Core.Scheduling
{
n--;
int k = random.Next(n + 1);
GroupedMediaItem value = copy[k];
copy[k] = copy[n];
copy[n] = value;
(copy[k], copy[n]) = (copy[n], copy[k]);
}
return GroupedMediaItem.FlattenGroups(copy, _mediaItemCount);

View File

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

View File

@@ -0,0 +1,9 @@
using System;
namespace ErsatzTV.Core
{
public static class SystemTime
{
public static DateTime MinValueUtc = new(0, DateTimeKind.Utc);
}
}

View File

@@ -22,6 +22,19 @@ namespace ErsatzTV.Infrastructure.Data.Configurations
.HasForeignKey(mci => mci.MultiCollectionId)
.OnDelete(DeleteBehavior.Cascade),
j => j.HasKey(mci => new { mci.MultiCollectionId, mci.CollectionId }));
builder.HasMany(m => m.SmartCollections)
.WithMany(m => m.MultiCollections)
.UsingEntity<MultiCollectionSmartItem>(
j => j.HasOne(mci => mci.SmartCollection)
.WithMany(c => c.MultiCollectionSmartItems)
.HasForeignKey(mci => mci.SmartCollectionId)
.OnDelete(DeleteBehavior.Cascade),
j => j.HasOne(mci => mci.MultiCollection)
.WithMany(mc => mc.MultiCollectionSmartItems)
.HasForeignKey(mci => mci.MultiCollectionId)
.OnDelete(DeleteBehavior.Cascade),
j => j.HasKey(mci => new { mci.MultiCollectionId, mci.SmartCollectionId }));
}
}
}

View File

@@ -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");
}
}

View File

@@ -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
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;
}
@@ -60,6 +66,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
Option<MultiCollection> maybeMultiCollection = await dbContext.MultiCollections
.Include(mc => mc.Collections)
.Include(mc => mc.SmartCollections)
.SelectOneAsync(mc => mc.Id, mc => mc.Id == id);
foreach (MultiCollection multiCollection in maybeMultiCollection)
@@ -73,11 +80,62 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
result.AddRange(await GetArtistItems(dbContext, collectionId));
result.AddRange(await GetMusicVideoItems(dbContext, collectionId));
}
foreach (int smartCollectionId in multiCollection.SmartCollections.Map(c => c.Id))
{
result.AddRange(await GetSmartCollectionItems(smartCollectionId));
}
}
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));
foreach (int showId in searchResults.Items.Filter(i => i.Type == SearchIndex.ShowType).Map(i => i.Id))
{
result.AddRange(await GetShowItemsFromShowId(dbContext, showId));
}
foreach (int artistId in searchResults.Items.Filter(i => i.Type == SearchIndex.ArtistType)
.Map(i => i.Id))
{
result.AddRange(await GetArtistItemsFromArtistId(dbContext, artistId));
}
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();
@@ -86,8 +144,11 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
Option<MultiCollection> maybeMultiCollection = await dbContext.MultiCollections
.Include(mc => mc.Collections)
.Include(mc => mc.SmartCollections)
.Include(mc => mc.MultiCollectionItems)
.ThenInclude(mci => mci.Collection)
.Include(mc => mc.MultiCollectionSmartItems)
.ThenInclude(mci => mci.SmartCollection)
.SelectOneAsync(mc => mc.Id, mc => mc.Id == id);
foreach (MultiCollection multiCollection in maybeMultiCollection)
@@ -126,6 +187,19 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
multiCollectionItem.Collection.UseCustomPlaybackOrder));
}
}
foreach (MultiCollectionSmartItem multiCollectionSmartItem in multiCollection.MultiCollectionSmartItems)
{
List<MediaItem> items = await GetSmartCollectionItems(multiCollectionSmartItem.SmartCollectionId);
result.Add(
new CollectionWithItems(
multiCollectionSmartItem.SmartCollectionId,
items,
multiCollectionSmartItem.ScheduleAsGroup,
multiCollectionSmartItem.PlaybackOrder,
false));
}
}
// remove duplicate items from ungrouped collections
@@ -143,6 +217,93 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
return result;
}
public async Task<List<CollectionWithItems>> GetFakeMultiCollectionCollections(
int? collectionId,
int? smartCollectionId)
{
var items = new List<MediaItem>();
if (collectionId.HasValue)
{
items = await GetItems(collectionId.Value);
}
if (smartCollectionId.HasValue)
{
items = await GetSmartCollectionItems(smartCollectionId.Value);
}
return GroupIntoFakeCollections(items);
}
private static List<CollectionWithItems> GroupIntoFakeCollections(List<MediaItem> items)
{
int id = -1;
var result = new List<CollectionWithItems>();
var showCollections = new Dictionary<int, List<MediaItem>>();
foreach (Episode episode in items.OfType<Episode>())
{
List<MediaItem> list = showCollections.ContainsKey(episode.Season.ShowId)
? showCollections[episode.Season.ShowId]
: new List<MediaItem>();
if (list.All(i => i.Id != episode.Id))
{
list.Add(episode);
}
showCollections[episode.Season.ShowId] = list;
}
foreach ((int _, List<MediaItem> list) in showCollections)
{
result.Add(
new CollectionWithItems(
id--,
list,
true,
PlaybackOrder.Chronological,
false));
}
var artistCollections = new Dictionary<int, List<MediaItem>>();
foreach (MusicVideo musicVideo in items.OfType<MusicVideo>())
{
List<MediaItem> list = artistCollections.ContainsKey(musicVideo.ArtistId)
? artistCollections[musicVideo.ArtistId]
: new List<MediaItem>();
if (list.All(i => i.Id != musicVideo.Id))
{
list.Add(musicVideo);
}
artistCollections[musicVideo.ArtistId] = list;
}
foreach ((int _, List<MediaItem> list) in artistCollections)
{
result.Add(
new CollectionWithItems(
id--,
list,
true,
PlaybackOrder.Chronological,
false));
}
result.Add(
new CollectionWithItems(
id,
items.OfType<Movie>().Cast<MediaItem>().ToList(),
true,
PlaybackOrder.Chronological,
false));
return result;
}
public Task<List<int>> PlayoutIdsUsingCollection(int collectionId) =>
_dbConnection.QueryAsync<int>(
@"SELECT DISTINCT p.PlayoutId
@@ -159,6 +320,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 +341,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 +360,30 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
WHERE ci.CollectionId = @CollectionId",
new { CollectionId = collectionId });
return await dbContext.MusicVideos
return await GetArtistItemsFromMusicVideoIds(dbContext, ids);
}
private static Task<List<MusicVideo>> GetArtistItemsFromMusicVideoIds(
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<MusicVideo>> GetArtistItemsFromArtistId(TvContext dbContext, int artistId)
{
IEnumerable<int> ids = await _dbConnection.QueryAsync<int>(
@"SELECT MusicVideo.Id FROM Artist
INNER JOIN MusicVideo on Artist.Id = MusicVideo.ArtistId
WHERE Artist.Id = @ArtistId",
new { ArtistId = artistId });
return await GetArtistItemsFromMusicVideoIds(dbContext, ids);
}
private async Task<List<MusicVideo>> GetMusicVideoItems(TvContext dbContext, int collectionId)
{
@@ -206,14 +393,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,14 +415,29 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
WHERE ci.CollectionId = @CollectionId",
new { CollectionId = collectionId });
return await dbContext.Episodes
return await GetShowItemsFromEpisodeIds(dbContext, ids);
}
private static Task<List<Episode>> GetShowItemsFromEpisodeIds(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();
private async Task<List<Episode>> GetShowItemsFromShowId(TvContext dbContext, int showId)
{
IEnumerable<int> ids = await _dbConnection.QueryAsync<int>(
@"SELECT Episode.Id FROM Show
INNER JOIN Season ON Season.ShowId = Show.Id
INNER JOIN Episode ON Episode.SeasonId = Season.Id
WHERE Show.Id = @ShowId",
new { ShowId = showId });
return await GetShowItemsFromEpisodeIds(dbContext, ids);
}
private async Task<List<Episode>> GetSeasonItems(TvContext dbContext, int collectionId)
@@ -244,14 +449,28 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
WHERE ci.CollectionId = @CollectionId",
new { CollectionId = collectionId });
return await dbContext.Episodes
return await GetSeasonItemsFromEpisodeIds(dbContext, ids);
}
private static Task<List<Episode>> GetSeasonItemsFromEpisodeIds(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();
private async Task<List<Episode>> GetSeasonItemsFromSeasonId(TvContext dbContext, int seasonId)
{
IEnumerable<int> ids = await _dbConnection.QueryAsync<int>(
@"SELECT Episode.Id FROM Season
INNER JOIN Episode ON Episode.SeasonId = Season.Id
WHERE Season.Id = @SeasonId",
new { SeasonId = seasonId });
return await GetSeasonItemsFromEpisodeIds(dbContext, ids);
}
private async Task<List<Episode>> GetEpisodeItems(TvContext dbContext, int collectionId)
@@ -262,14 +481,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();
}
}
}

View File

@@ -703,7 +703,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
new()
{
DateAdded = DateTime.UtcNow,
DateUpdated = DateTime.MinValue,
DateUpdated = SystemTime.MinValueUtc,
MetadataKind = MetadataKind.Fallback,
Actors = new List<Actor>(),
Guids = new List<MetadataGuid>(),

View File

@@ -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; }

View File

@@ -16,17 +16,17 @@
<PackageReference Include="Lucene.Net" Version="4.8.0-beta00014" />
<PackageReference Include="Lucene.Net.Analysis.Common" Version="4.8.0-beta00014" />
<PackageReference Include="Lucene.Net.QueryParser" Version="4.8.0-beta00014" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="5.0.8" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="5.0.8">
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="5.0.10" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="5.0.10">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="5.0.8" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="5.0.10" />
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="16.10.56">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Refit" Version="6.0.38" />
<PackageReference Include="Refit" Version="6.0.94" />
<PackageReference Include="SixLabors.ImageSharp" Version="1.0.3" />
</ItemGroup>

File diff suppressed because it is too large Load Diff

View File

@@ -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");
}
}
}

View File

@@ -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");
}
}
}

View File

@@ -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");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,47 @@
using Microsoft.EntityFrameworkCore.Migrations;
namespace ErsatzTV.Infrastructure.Migrations
{
public partial class Add_MultiCollectionSmartCollection : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "MultiCollectionSmartItem",
columns: table => new
{
MultiCollectionId = table.Column<int>(type: "INTEGER", nullable: false),
SmartCollectionId = table.Column<int>(type: "INTEGER", nullable: false),
ScheduleAsGroup = table.Column<bool>(type: "INTEGER", nullable: false),
PlaybackOrder = table.Column<int>(type: "INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_MultiCollectionSmartItem", x => new { x.MultiCollectionId, x.SmartCollectionId });
table.ForeignKey(
name: "FK_MultiCollectionSmartItem_MultiCollection_MultiCollectionId",
column: x => x.MultiCollectionId,
principalTable: "MultiCollection",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_MultiCollectionSmartItem_SmartCollection_SmartCollectionId",
column: x => x.SmartCollectionId,
principalTable: "SmartCollection",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_MultiCollectionSmartItem_SmartCollectionId",
table: "MultiCollectionSmartItem",
column: "SmartCollectionId");
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "MultiCollectionSmartItem");
}
}
}

View File

@@ -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 =>
{
@@ -979,6 +979,27 @@ namespace ErsatzTV.Infrastructure.Migrations
b.ToTable("MultiCollectionItem");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.MultiCollectionSmartItem", b =>
{
b.Property<int>("MultiCollectionId")
.HasColumnType("INTEGER");
b.Property<int>("SmartCollectionId")
.HasColumnType("INTEGER");
b.Property<int>("PlaybackOrder")
.HasColumnType("INTEGER");
b.Property<bool>("ScheduleAsGroup")
.HasColumnType("INTEGER");
b.HasKey("MultiCollectionId", "SmartCollectionId");
b.HasIndex("SmartCollectionId");
b.ToTable("MultiCollectionSmartItem");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.MusicVideoMetadata", b =>
{
b.Property<int>("Id")
@@ -1106,6 +1127,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 +1142,8 @@ namespace ErsatzTV.Infrastructure.Migrations
b.HasIndex("ProgramScheduleId");
b.HasIndex("SmartCollectionId");
b.ToTable("PlayoutProgramScheduleAnchor");
});
@@ -1218,6 +1244,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 +1260,8 @@ namespace ErsatzTV.Infrastructure.Migrations
b.HasIndex("ProgramScheduleId");
b.HasIndex("SmartCollectionId");
b.ToTable("ProgramScheduleItem");
});
@@ -1349,6 +1380,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")
@@ -2220,6 +2268,25 @@ namespace ErsatzTV.Infrastructure.Migrations
b.Navigation("MultiCollection");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.MultiCollectionSmartItem", b =>
{
b.HasOne("ErsatzTV.Core.Domain.MultiCollection", "MultiCollection")
.WithMany("MultiCollectionSmartItems")
.HasForeignKey("MultiCollectionId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("ErsatzTV.Core.Domain.SmartCollection", "SmartCollection")
.WithMany("MultiCollectionSmartItems")
.HasForeignKey("SmartCollectionId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("MultiCollection");
b.Navigation("SmartCollection");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.MusicVideoMetadata", b =>
{
b.HasOne("ErsatzTV.Core.Domain.MusicVideo", "MusicVideo")
@@ -2338,6 +2405,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 +2439,8 @@ namespace ErsatzTV.Infrastructure.Migrations
b.Navigation("Playout");
b.Navigation("ProgramSchedule");
b.Navigation("SmartCollection");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.PlexConnection", b =>
@@ -2415,6 +2488,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 +2499,8 @@ namespace ErsatzTV.Infrastructure.Migrations
b.Navigation("MultiCollection");
b.Navigation("ProgramSchedule");
b.Navigation("SmartCollection");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.SeasonMetadata", b =>
@@ -2933,6 +3012,8 @@ namespace ErsatzTV.Infrastructure.Migrations
modelBuilder.Entity("ErsatzTV.Core.Domain.MultiCollection", b =>
{
b.Navigation("MultiCollectionItems");
b.Navigation("MultiCollectionSmartItems");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.MusicVideoMetadata", b =>
@@ -2994,6 +3075,11 @@ namespace ErsatzTV.Infrastructure.Migrations
b.Navigation("Tags");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.SmartCollection", b =>
{
b.Navigation("MultiCollectionSmartItems");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.Artist", b =>
{
b.Navigation("ArtistMetadata");

View File

@@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Threading.Tasks;
using System.Xml.Serialization;
using ErsatzTV.Core;
@@ -35,7 +36,12 @@ namespace ErsatzTV.Infrastructure.Plex
{
try
{
IPlexServerApi service = RestService.For<IPlexServerApi>(connection.Uri);
IPlexServerApi service = RestService.For<IPlexServerApi>(
new HttpClient
{
BaseAddress = new Uri(connection.Uri),
Timeout = TimeSpan.FromSeconds(10)
});
List<PlexLibraryResponse> directory =
await service.GetLibraries(token.AuthToken).Map(r => r.MediaContainer.Directory);
return directory

View File

@@ -0,0 +1,58 @@
using System;
using System.Collections.Generic;
using Lucene.Net.Analysis;
using Lucene.Net.QueryParsers.Classic;
using Lucene.Net.Search;
using Lucene.Net.Util;
namespace ErsatzTV.Infrastructure.Search
{
public class CustomMultiFieldQueryParser : MultiFieldQueryParser
{
public CustomMultiFieldQueryParser(
LuceneVersion matchVersion,
string[] fields,
Analyzer analyzer,
IDictionary<string, float> boosts) : base(matchVersion, fields, analyzer, boosts)
{
}
public CustomMultiFieldQueryParser(LuceneVersion matchVersion, string[] fields, Analyzer analyzer) : base(
matchVersion,
fields,
analyzer)
{
}
protected override Query GetFieldQuery(string field, string queryText, bool quoted)
{
if (field == "released_onthisday")
{
var todayString = DateTime.Today.ToString("*MMdd");
return base.GetWildcardQuery("release_date", todayString);
}
return base.GetFieldQuery(field, queryText, quoted);
}
protected override Query GetFieldQuery(string field, string queryText, int slop)
{
if (field == "released_inthelast" && CustomQueryParser.ParseStart(queryText, out DateTime start))
{
var todayString = DateTime.Today.ToString("yyyyMMdd");
var dateString = start.ToString("yyyyMMdd");
return base.GetRangeQuery("release_date", dateString, todayString, true, true);
}
if (field == "released_notinthelast" && CustomQueryParser.ParseStart(queryText, out DateTime finish))
{
var dateString = finish.ToString("yyyyMMdd");
return base.GetRangeQuery("release_date", "00000000", dateString, false, false);
}
return base.GetFieldQuery(field, queryText, slop);
}
}
}

View File

@@ -0,0 +1,96 @@
using System;
using ErsatzTV.Core;
using Lucene.Net.Analysis;
using Lucene.Net.QueryParsers.Classic;
using Lucene.Net.Search;
using Lucene.Net.Util;
namespace ErsatzTV.Infrastructure.Search
{
public class CustomQueryParser : QueryParser
{
public CustomQueryParser(LuceneVersion matchVersion, string f, Analyzer a) : base(matchVersion, f, a)
{
}
protected internal CustomQueryParser(ICharStream stream) : base(stream)
{
}
protected CustomQueryParser(QueryParserTokenManager tm) : base(tm)
{
}
protected override Query GetFieldQuery(string field, string queryText, bool quoted)
{
if (field == "released_onthisday")
{
var todayString = DateTime.Today.ToString("*MMdd");
return base.GetWildcardQuery("release_date", todayString);
}
return base.GetFieldQuery(field, queryText, quoted);
}
protected override Query GetFieldQuery(string field, string queryText, int slop)
{
if (field == "released_inthelast" && ParseStart(queryText, out DateTime start))
{
var todayString = DateTime.Today.ToString("yyyyMMdd");
var dateString = start.ToString("yyyyMMdd");
return base.GetRangeQuery("release_date", dateString, todayString, true, true);
}
if (field == "released_notinthelast" && ParseStart(queryText, out DateTime finish))
{
var dateString = finish.ToString("yyyyMMdd");
return base.GetRangeQuery("release_date", "00000000", dateString, false, false);
}
return base.GetFieldQuery(field, queryText, slop);
}
internal static bool ParseStart(string text, out DateTime start)
{
start = SystemTime.MinValueUtc;
try
{
if (int.TryParse(text.Split(" ")[0], out int number))
{
if (text.Contains("day"))
{
start = DateTime.Today.AddDays(number * -1);
return true;
}
if (text.Contains("week"))
{
start = DateTime.Today.AddDays(number * -7);
return true;
}
if (text.Contains("month"))
{
start = DateTime.Today.AddMonths(number * -1);
return true;
}
if (text.Contains("year"))
{
start = DateTime.Today.AddYears(number * -1);
return true;
}
}
}
catch
{
// do nothing
}
return false;
}
}
}

View File

@@ -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
_initialized = false;
}
public int Version => 14;
public int Version => 15;
public Task<bool> Initialize(ILocalFileSystem localFileSystem)
{
@@ -148,8 +148,8 @@ namespace ErsatzTV.Infrastructure.Search
};
using var analyzerWrapper = new PerFieldAnalyzerWrapper(analyzer, customAnalyzers);
QueryParser parser = !string.IsNullOrWhiteSpace(searchField)
? new QueryParser(AppLuceneVersion, searchField, analyzerWrapper)
: new MultiFieldQueryParser(AppLuceneVersion, new[] { TitleField }, analyzerWrapper);
? new CustomQueryParser(AppLuceneVersion, searchField, analyzerWrapper)
: new CustomMultiFieldQueryParser(AppLuceneVersion, new[] { TitleField }, analyzerWrapper);
parser.AllowLeadingWildcard = true;
Query query = ParseQuery(searchQuery, parser);
var filter = new DuplicateFilter(TitleAndYearField);
@@ -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
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
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
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
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
}
}
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)
{

View File

@@ -35,7 +35,10 @@ namespace ErsatzTV.Controllers
_httpClientFactory = httpClientFactory;
}
[HttpHead("/iptv/artwork/posters/{fileName}")]
[HttpGet("/iptv/artwork/posters/{fileName}")]
[HttpHead("/iptv/artwork/posters/{fileName}.jpg")]
[HttpGet("/iptv/artwork/posters/{fileName}.jpg")]
[HttpGet("/artwork/posters/{fileName}")]
public async Task<IActionResult> GetPoster(string fileName)
{
@@ -67,8 +70,10 @@ namespace ErsatzTV.Controllers
}
[HttpHead("/iptv/artwork/posters/jellyfin/{*path}")]
[HttpGet("/iptv/artwork/posters/jellyfin/{*path}")]
[HttpGet("/artwork/posters/jellyfin/{*path}")]
[HttpHead("/iptv/artwork/thumbnails/jellyfin/{*path}")]
[HttpGet("/iptv/artwork/thumbnails/jellyfin/{*path}")]
[HttpGet("/artwork/thumbnails/jellyfin/{*path}")]
[HttpGet("/artwork/fanart/jellyfin/{*path}")]
@@ -82,8 +87,10 @@ namespace ErsatzTV.Controllers
return GetJellyfinArtwork(path);
}
[HttpHead("/iptv/artwork/posters/emby/{*path}")]
[HttpGet("/iptv/artwork/posters/emby/{*path}")]
[HttpGet("/artwork/posters/emby/{*path}")]
[HttpHead("/iptv/artwork/thumbnails/emby/{*path}")]
[HttpGet("/iptv/artwork/thumbnails/emby/{*path}")]
[HttpGet("/artwork/thumbnails/emby/{*path}")]
[HttpGet("/artwork/fanart/emby/{*path}")]
@@ -97,6 +104,7 @@ namespace ErsatzTV.Controllers
return GetEmbyArtwork(path);
}
[HttpHead("/iptv/artwork/posters/plex/{plexMediaSourceId}/{*path}")]
[HttpGet("/iptv/artwork/posters/plex/{plexMediaSourceId}/{*path}")]
[HttpGet("/artwork/posters/plex/{plexMediaSourceId}/{*path}")]
public Task<IActionResult> GetPlexPoster(int plexMediaSourceId, string path) =>
@@ -116,7 +124,10 @@ namespace ErsatzTV.Controllers
plexMediaSourceId,
$"photo/:/transcode?url=/{path}&height=220&width=392&minSize=1&upscale=0");
[HttpHead("/iptv/artwork/thumbnails/{fileName}")]
[HttpGet("/iptv/artwork/thumbnails/{fileName}")]
[HttpHead("/iptv/artwork/thumbnails/{fileName}.jpg")]
[HttpGet("/iptv/artwork/thumbnails/{fileName}.jpg")]
[HttpGet("/artwork/thumbnails/{fileName}")]
public async Task<IActionResult> GetThumbnail(string fileName)
{

View File

@@ -71,6 +71,8 @@ namespace ErsatzTV.Controllers
error => BadRequest(error.Value)));
[HttpGet("iptv/logos/{fileName}")]
[HttpHead("iptv/logos/{fileName}.jpg")]
[HttpGet("iptv/logos/{fileName}.jpg")]
public async Task<IActionResult> GetImage(string fileName)
{
Either<BaseError, CachedImagePathViewModel> cachedImagePath =

View File

@@ -17,16 +17,16 @@
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Blazored.LocalStorage" Version="4.1.2" />
<PackageReference Include="FluentValidation" Version="10.3.0" />
<PackageReference Include="FluentValidation.AspNetCore" Version="10.3.0" />
<PackageReference Include="HtmlSanitizer" Version="5.0.404" />
<PackageReference Include="Blazored.LocalStorage" Version="4.1.5" />
<PackageReference Include="FluentValidation" Version="10.3.3" />
<PackageReference Include="FluentValidation.AspNetCore" Version="10.3.3" />
<PackageReference Include="HtmlSanitizer" Version="6.0.441" />
<PackageReference Include="LanguageExt.Core" Version="3.4.15" />
<PackageReference Include="Markdig" Version="0.25.0" />
<PackageReference Include="Markdig" Version="0.26.0" />
<PackageReference Include="MediatR.Courier.DependencyInjection" Version="3.0.1" />
<PackageReference Include="MediatR.Extensions.Microsoft.DependencyInjection" Version="9.0.0" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="5.0.8" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="5.0.8">
<PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="5.0.10" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="5.0.10">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
@@ -34,13 +34,13 @@
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="MudBlazor" Version="5.1.0" />
<PackageReference Include="MudBlazor" Version="5.1.4" />
<PackageReference Include="NaturalSort.Extension" Version="3.1.0" />
<PackageReference Include="PPioli.FluentValidation.Blazor" Version="5.0.0" />
<PackageReference Include="Refit.HttpClientFactory" Version="6.0.38" />
<PackageReference Include="Refit.HttpClientFactory" Version="6.0.94" />
<PackageReference Include="Serilog" Version="2.10.0" />
<PackageReference Include="Serilog.AspNetCore" Version="4.1.0" />
<PackageReference Include="Serilog.Settings.Configuration" Version="3.1.0" />
<PackageReference Include="Serilog.Settings.Configuration" Version="3.2.0" />
<PackageReference Include="Serilog.Sinks.SQLite" Version="5.0.0" />
<PackageReference Include="System.IO.FileSystem.Primitives" Version="4.3.0" />
<PackageReference Include="System.Text.Encoding.Extensions" Version="4.3.0" />

View File

@@ -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");
}
}

View File

@@ -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 @@
<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 @@
_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 @@
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 @@
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 };
}
}

View File

@@ -12,7 +12,7 @@
<MudTable Hover="true" Items="_ffmpegProfiles">
<ToolBarContent>
<MudText Typo="Typo.h6">FFmpeg Profiles</MudText>
<MudToolBarSpacer></MudToolBarSpacer>
<MudSpacer/>
<MudText Color="Color.Tertiary">Colored settings will be normalized</MudText>
</ToolBarContent>
<ColGroup>

View File

@@ -24,11 +24,21 @@
@bind-value="_selectedCollection"
SearchFunc="@SearchCollections"
ToStringFunc="@(c => c?.Name)"/>
</MudCardContent>
<MudCardActions>
<MudButton Variant="Variant.Filled" Color="Color.Secondary" OnClick="@(_ => AddCollection())" Class="ml-2">
<MudButton Variant="Variant.Filled" Color="Color.Secondary" OnClick="@(_ => AddCollection())" Class="mt-4 mr-auto">
Add Collection
</MudButton>
<MudAutocomplete @ref="_smartCollectionAutocomplete"
Class="mt-4"
T="SmartCollectionViewModel"
Label="Smart Collection"
@bind-value="_selectedSmartCollection"
SearchFunc="@SearchSmartCollections"
ToStringFunc="@(c => c?.Name)"/>
<MudButton Variant="Variant.Filled" Color="Color.Secondary" OnClick="@(_ => AddSmartCollection())" Class="mt-4 mr-auto">
Add Smart Collection
</MudButton>
</MudCardContent>
<MudCardActions>
<MudButton ButtonType="ButtonType.Submit" Variant="Variant.Filled" Color="Color.Primary" Class="mr-2 ml-auto">
@(IsEdit ? "Save Changes" : "Add Multi Collection")
</MudButton>
@@ -87,14 +97,20 @@
private EditContext _editContext;
private ValidationMessageStore _messageStore;
private List<MediaCollectionViewModel> _collections;
private List<SmartCollectionViewModel> _smartCollections;
private MediaCollectionViewModel _selectedCollection;
private SmartCollectionViewModel _selectedSmartCollection;
private MudAutocomplete<MediaCollectionViewModel> _collectionAutocomplete;
private MudAutocomplete<SmartCollectionViewModel> _smartCollectionAutocomplete;
protected override async Task OnParametersSetAsync()
{
_collections = await _mediator.Send(new GetAllCollections())
.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());
if (IsEdit)
{
Option<MultiCollectionViewModel> maybeCollection = await _mediator.Send(new GetMultiCollectionById(Id));
@@ -102,13 +118,21 @@
{
_model.Id = collection.Id;
_model.Name = collection.Name;
_model.Items = collection.Items.Map(item =>
new MultiCollectionItemEditViewModel
{
Collection = item.Collection,
ScheduleAsGroup = item.ScheduleAsGroup,
PlaybackOrder = item.PlaybackOrder,
}).ToList();
_model.Items = collection.Items
.Map(item =>
new MultiCollectionItemEditViewModel
{
Collection = item.Collection,
ScheduleAsGroup = item.ScheduleAsGroup,
PlaybackOrder = item.PlaybackOrder,
})
.Append(collection.SmartItems.Map(item =>
new MultiCollectionSmartItemEditViewModel
{
SmartCollection = item.SmartCollection,
ScheduleAsGroup = item.ScheduleAsGroup,
PlaybackOrder = item.PlaybackOrder
})).ToList();
});
}
else
@@ -132,8 +156,8 @@
if (_editContext.Validate())
{
Seq<BaseError> errorMessage = IsEdit ?
(await _mediator.Send(new UpdateMultiCollection(Id, _model.Name, _model.Items.Map(i => new UpdateMultiCollectionItem(i.Collection.Id, i.ScheduleAsGroup, i.PlaybackOrder)).ToList()))).LeftToSeq() :
(await _mediator.Send(new CreateMultiCollection(_model.Name, _model.Items.Map(i => new CreateMultiCollectionItem(i.Collection.Id, i.ScheduleAsGroup, i.PlaybackOrder)).ToList()))).LeftToSeq();
(await _mediator.Send(new UpdateMultiCollection(Id, _model.Name, GetUpdateItems()))).LeftToSeq() :
(await _mediator.Send(new CreateMultiCollection(_model.Name, GetCreateItems()))).LeftToSeq();
errorMessage.HeadOrNone().Match(
error =>
@@ -145,6 +169,32 @@
}
}
private List<UpdateMultiCollectionItem> GetUpdateItems() =>
_model.Items.Map(i =>
i switch
{
MultiCollectionSmartItemEditViewModel smartVm =>
new UpdateMultiCollectionItem(
null,
smartVm.SmartCollection.Id,
smartVm.ScheduleAsGroup,
smartVm.PlaybackOrder),
_ => new UpdateMultiCollectionItem(i.Collection.Id, null, i.ScheduleAsGroup, i.PlaybackOrder)
}).ToList();
private List<CreateMultiCollectionItem> GetCreateItems() =>
_model.Items.Map(i =>
i switch
{
MultiCollectionSmartItemEditViewModel smartVm =>
new CreateMultiCollectionItem(
null,
smartVm.SmartCollection.Id,
smartVm.ScheduleAsGroup,
smartVm.PlaybackOrder),
_ => new CreateMultiCollectionItem(i.Collection.Id, null, i.ScheduleAsGroup, i.PlaybackOrder)
}).ToList();
private void RemoveCollection(MultiCollectionItemEditViewModel item)
{
_model.Items.Remove(item);
@@ -165,7 +215,25 @@
}
}
private void AddSmartCollection()
{
if (_selectedSmartCollection != null && _model.Items.OfType<MultiCollectionSmartItemEditViewModel>().All(i => i.SmartCollection != _selectedSmartCollection))
{
_model.Items.Add(new MultiCollectionSmartItemEditViewModel
{
SmartCollection = _selectedSmartCollection,
PlaybackOrder = PlaybackOrder.Chronological
});
_selectedSmartCollection = null;
_smartCollectionAutocomplete.Reset();
}
}
private Task<IEnumerable<MediaCollectionViewModel>> SearchCollections(string value) =>
_collections.Filter(c => _model.Items.All(i => i.Collection != c) && c.Name.Contains(value ?? string.Empty, StringComparison.OrdinalIgnoreCase)).AsTask();
private Task<IEnumerable<SmartCollectionViewModel>> SearchSmartCollections(string value) =>
_smartCollections.Filter(c => _model.Items.OfType<MultiCollectionSmartItemEditViewModel>().All(i => i.SmartCollection != c) && c.Name.Contains(value ?? string.Empty, StringComparison.OrdinalIgnoreCase)).AsTask();
}

Some files were not shown because too many files have changed in this diff Show More