Compare commits
23
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8417c3f6cd | ||
|
|
32fdb414fa | ||
|
|
d3fc820aef | ||
|
|
9d07627781 | ||
|
|
d3c8914758 | ||
|
|
3d7ec59088 | ||
|
|
d78976f80a | ||
|
|
0f5fee99c6 | ||
|
|
d5bfd1a254 | ||
|
|
cd2558e3e6 | ||
|
|
c39654ca40 | ||
|
|
f17151bd20 | ||
|
|
6aeaf65a13 | ||
|
|
9fbe950e6e | ||
|
|
c9baff2cd5 | ||
|
|
447829385f | ||
|
|
a94d831866 | ||
|
|
632753ea93 | ||
|
|
4000c6bc0a | ||
|
|
1521469b2f | ||
|
|
d6272c54a0 | ||
|
|
d5039dc4fc | ||
|
|
eba50523a9 |
@@ -0,0 +1,71 @@
|
||||
# For most projects, this workflow file will not need changing; you simply need
|
||||
# to commit it to your repository.
|
||||
#
|
||||
# You may wish to alter this file to override the set of languages analyzed,
|
||||
# or to provide custom queries or build logic.
|
||||
#
|
||||
# ******** NOTE ********
|
||||
# We have attempted to detect the languages in your repository. Please check
|
||||
# the `language` matrix defined below to confirm you have the correct set of
|
||||
# supported CodeQL languages.
|
||||
#
|
||||
name: "CodeQL"
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main ]
|
||||
pull_request:
|
||||
# The branches below must be a subset of the branches above
|
||||
branches: [ main ]
|
||||
schedule:
|
||||
- cron: '30 3 * * 0'
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
name: Analyze
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
security-events: write
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
language: [ 'csharp' ]
|
||||
# CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ]
|
||||
# Learn more:
|
||||
# https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#changing-the-languages-that-are-analyzed
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v2
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v1
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
# If you wish to specify custom queries, you can do so here or in a config file.
|
||||
# By default, queries listed here will override any specified in a config file.
|
||||
# Prefix the list here with "+" to use these queries and those in the config file.
|
||||
# queries: ./path/to/local/query, your-org/your-repo/queries@main
|
||||
|
||||
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
|
||||
# If this step fails, then you should remove it and run the build manually (see below)
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@v1
|
||||
|
||||
# ℹ️ Command-line programs to run using the OS shell.
|
||||
# 📚 https://git.io/JvXDl
|
||||
|
||||
# ✏️ If the Autobuild fails above, remove it and uncomment the following three lines
|
||||
# and modify them (or add more) to build your code if your project
|
||||
# uses a compiled language
|
||||
|
||||
#- run: |
|
||||
# make bootstrap
|
||||
# make release
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v1
|
||||
@@ -42,24 +42,22 @@ jobs:
|
||||
# Define some variables for things we need
|
||||
tag=$(git describe --tags --abbrev=0)
|
||||
release_name="ErsatzTV-$tag-${{ matrix.target }}"
|
||||
#release_name_cli="ErsatzTV.CommandLine-$tag-${{ matrix.target }}"
|
||||
|
||||
# Build everything
|
||||
dotnet publish ErsatzTV/ErsatzTV.csproj --framework net5.0 --runtime "${{ matrix.target }}" -c Release -o "$release_name" /property:InformationalVersion="${tag:1}-${{ matrix.target }}" /property:PublishSingleFile=true --self-contained true
|
||||
#dotnet publish ErsatzTV.CommandLine/ErsatzTV.CommandLine.csproj --framework net5.0 --runtime "${{ matrix.target }}" -c Release -o "$release_name_cli" /property:InformationalVersion="${tag:1}-${{ matrix.target }}"
|
||||
|
||||
# Pack files
|
||||
if [ "${{ matrix.target }}" == "win-x64" ]; then
|
||||
7z a -tzip "${release_name}.zip" "./${release_name}/*"
|
||||
#7z a -tzip "${release_name_cli}.zip" "./${release_name_cli}/*"
|
||||
elif [ "${{ matrix.target }}" == "linux-arm" ]; then
|
||||
cp lib/linux-arm/* "$release_name/"
|
||||
tar czvf "${release_name}.tar.gz" "$release_name"
|
||||
else
|
||||
tar czvf "${release_name}.tar.gz" "$release_name"
|
||||
#tar czvf "${release_name_cli}.tar.gz" "$release_name_cli"
|
||||
fi
|
||||
|
||||
# Delete output directory
|
||||
rm -r "$release_name"
|
||||
#rm -r "$release_name_cli"
|
||||
|
||||
- name: Publish
|
||||
uses: softprops/action-gh-release@v1
|
||||
|
||||
+46
-1
@@ -5,6 +5,47 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [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
|
||||
- Fix Emby and Jellyfin links
|
||||
|
||||
### Changed
|
||||
- Always proxy Jellyfin and Emby artwork; this fixes artwork in some networking scenarios
|
||||
|
||||
## [0.0.52-alpha] - 2021-07-22
|
||||
### Added
|
||||
- Add multiple local libraries to better organize your media
|
||||
- Add `Move Library Path` function to support reorganizing existing local libraries
|
||||
|
||||
### Fixed
|
||||
- Fix bug preventing playouts from rebuilding after an empty collection is encountered within a multi-collection
|
||||
|
||||
## [0.0.51-alpha] - 2021-07-18
|
||||
### Added
|
||||
- Add `Multi Collection` to support shuffling multiple collections within a single schedule item
|
||||
- Collections within a multi collection are optionally grouped together and ordered when scheduling; this can be useful for franchises
|
||||
- Add `Playout Days To Build` setting to control how many days of playout data/program guide data should be built into the future
|
||||
|
||||
### Changed
|
||||
- Move `Playback Order` from schedule to schedule items
|
||||
- This allows different schedule items to have different playback orders within a single schedule
|
||||
|
||||
### Fixed
|
||||
- Fix release notes on home page with `-alpha` suffix
|
||||
- Fix linux-arm release by including SQLite interop artifacts
|
||||
- Fix issue where cached Plex credentials may become invalid when multiple servers are used
|
||||
|
||||
## [0.0.50-alpha] - 2021-07-13
|
||||
### Added
|
||||
- Add Linux ARM release artifacts which can be used on Raspberry Pi devices
|
||||
@@ -505,7 +546,11 @@ 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.50-alpha...HEAD
|
||||
[Unreleased]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.54-alpha...HEAD
|
||||
[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
|
||||
[0.0.50-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.49-prealpha...v0.0.50-alpha
|
||||
[0.0.49-prealpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.48-prealpha...v0.0.49-prealpha
|
||||
[0.0.48-prealpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.47-prealpha...v0.0.48-prealpha
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
using ErsatzTV.Core;
|
||||
using LanguageExt;
|
||||
|
||||
namespace ErsatzTV.Application.Configuration.Commands
|
||||
{
|
||||
public record UpdatePlayoutDaysToBuild(int DaysToBuild) : MediatR.IRequest<Either<BaseError, Unit>>;
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
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 LanguageExt;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using static LanguageExt.Prelude;
|
||||
|
||||
namespace ErsatzTV.Application.Configuration.Commands
|
||||
{
|
||||
public class
|
||||
UpdatePlayoutDaysToBuildHandler : MediatR.IRequestHandler<UpdatePlayoutDaysToBuild, Either<BaseError, Unit>>
|
||||
{
|
||||
private readonly IConfigElementRepository _configElementRepository;
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||
private readonly ChannelWriter<IBackgroundServiceRequest> _workerChannel;
|
||||
|
||||
public UpdatePlayoutDaysToBuildHandler(
|
||||
IConfigElementRepository configElementRepository,
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
ChannelWriter<IBackgroundServiceRequest> workerChannel)
|
||||
{
|
||||
_configElementRepository = configElementRepository;
|
||||
_dbContextFactory = dbContextFactory;
|
||||
_workerChannel = workerChannel;
|
||||
}
|
||||
|
||||
public async Task<Either<BaseError, Unit>> Handle(
|
||||
UpdatePlayoutDaysToBuild request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||
Validation<BaseError, Unit> validation = await Validate(request);
|
||||
return await validation.Apply<Unit, Unit>(_ => ApplyUpdate(dbContext, request.DaysToBuild));
|
||||
}
|
||||
|
||||
private async Task<Unit> ApplyUpdate(TvContext dbContext, int daysToBuild)
|
||||
{
|
||||
await _configElementRepository.Upsert(ConfigElementKey.PlayoutDaysToBuild, daysToBuild);
|
||||
|
||||
// build all playouts to proper number of days
|
||||
List<Playout> playouts = await dbContext.Playouts
|
||||
.Include(p => p.Channel)
|
||||
.ToListAsync();
|
||||
foreach (int playoutId in playouts.OrderBy(p => decimal.Parse(p.Channel.Number)).Map(p => p.Id))
|
||||
{
|
||||
await _workerChannel.WriteAsync(new BuildPlayout(playoutId));
|
||||
}
|
||||
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
private static Task<Validation<BaseError, Unit>> Validate(UpdatePlayoutDaysToBuild request) =>
|
||||
Optional(request.DaysToBuild)
|
||||
.Filter(days => days > 0)
|
||||
.Map(_ => Unit.Default)
|
||||
.ToValidation<BaseError>("Days to build must be greater than zero")
|
||||
.AsTask();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
using MediatR;
|
||||
|
||||
namespace ErsatzTV.Application.Configuration.Queries
|
||||
{
|
||||
public record GetPlayoutDaysToBuild : IRequest<int>;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using LanguageExt;
|
||||
using MediatR;
|
||||
|
||||
namespace ErsatzTV.Application.Configuration.Queries
|
||||
{
|
||||
public class GetPlayoutDaysToBuildHandler : IRequestHandler<GetPlayoutDaysToBuild, int>
|
||||
{
|
||||
private readonly IConfigElementRepository _configElementRepository;
|
||||
|
||||
public GetPlayoutDaysToBuildHandler(IConfigElementRepository configElementRepository) =>
|
||||
_configElementRepository = configElementRepository;
|
||||
|
||||
public Task<int> Handle(GetPlayoutDaysToBuild request, CancellationToken cancellationToken) =>
|
||||
_configElementRepository.GetValue<int>(ConfigElementKey.PlayoutDaysToBuild)
|
||||
.Map(result => result.IfNone(2));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
using System.Collections.Generic;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using LanguageExt;
|
||||
using MediatR;
|
||||
|
||||
namespace ErsatzTV.Application.Libraries.Commands
|
||||
{
|
||||
public record CreateLocalLibrary(string Name, LibraryMediaKind MediaKind, List<string> Paths)
|
||||
: ILocalLibraryRequest, IRequest<Either<BaseError, LocalLibraryViewModel>>;
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Channels;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Application.MediaSources.Commands;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Locking;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using LanguageExt;
|
||||
using MediatR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using static ErsatzTV.Application.Libraries.Mapper;
|
||||
using static LanguageExt.Prelude;
|
||||
|
||||
namespace ErsatzTV.Application.Libraries.Commands
|
||||
{
|
||||
public class CreateLocalLibraryHandler : LocalLibraryHandlerBase,
|
||||
IRequestHandler<CreateLocalLibrary, Either<BaseError, LocalLibraryViewModel>>
|
||||
{
|
||||
private readonly ChannelWriter<IBackgroundServiceRequest> _workerChannel;
|
||||
private readonly IEntityLocker _entityLocker;
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||
|
||||
public CreateLocalLibraryHandler(
|
||||
ChannelWriter<IBackgroundServiceRequest> workerChannel,
|
||||
IEntityLocker entityLocker,
|
||||
IDbContextFactory<TvContext> dbContextFactory)
|
||||
{
|
||||
_workerChannel = workerChannel;
|
||||
_entityLocker = entityLocker;
|
||||
_dbContextFactory = dbContextFactory;
|
||||
}
|
||||
|
||||
public async Task<Either<BaseError, LocalLibraryViewModel>> Handle(
|
||||
CreateLocalLibrary request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||
Validation<BaseError, LocalLibrary> validation = await Validate(dbContext, request);
|
||||
return await validation.Apply(localLibrary => PersistLocalLibrary(dbContext, localLibrary));
|
||||
}
|
||||
|
||||
private async Task<LocalLibraryViewModel> PersistLocalLibrary(
|
||||
TvContext dbContext,
|
||||
LocalLibrary localLibrary)
|
||||
{
|
||||
await dbContext.LocalLibraries.AddAsync(localLibrary);
|
||||
await dbContext.SaveChangesAsync();
|
||||
|
||||
if (_entityLocker.LockLibrary(localLibrary.Id))
|
||||
{
|
||||
await _workerChannel.WriteAsync(new ForceScanLocalLibrary(localLibrary.Id));
|
||||
}
|
||||
|
||||
return ProjectToViewModel(localLibrary);
|
||||
}
|
||||
|
||||
private static Task<Validation<BaseError, LocalLibrary>> Validate(
|
||||
TvContext dbContext,
|
||||
CreateLocalLibrary request) =>
|
||||
MediaSourceMustExist(dbContext, request)
|
||||
.BindT(localLibrary => NameMustBeValid(request, localLibrary))
|
||||
.BindT(localLibrary => PathsMustBeValid(dbContext, localLibrary));
|
||||
|
||||
private static Task<Validation<BaseError, LocalLibrary>> MediaSourceMustExist(
|
||||
TvContext dbContext,
|
||||
CreateLocalLibrary request) =>
|
||||
dbContext.LocalMediaSources
|
||||
.OrderBy(lms => lms.Id)
|
||||
.FirstOrDefaultAsync()
|
||||
.Map(Optional)
|
||||
.MapT(
|
||||
lms => new LocalLibrary
|
||||
{
|
||||
Name = request.Name,
|
||||
Paths = request.Paths.Map(p => new LibraryPath { Path = p }).ToList(),
|
||||
MediaKind = request.MediaKind,
|
||||
MediaSourceId = lms.Id
|
||||
})
|
||||
.Map(o => o.ToValidation<BaseError>("LocalMediaSource does not exist."));
|
||||
}
|
||||
}
|
||||
@@ -19,8 +19,8 @@ namespace ErsatzTV.Application.Libraries.Commands
|
||||
{
|
||||
private readonly ILibraryRepository _libraryRepository;
|
||||
|
||||
public CreateLocalLibraryPathHandler(ILibraryRepository mediaSourceRepository) =>
|
||||
_libraryRepository = mediaSourceRepository;
|
||||
public CreateLocalLibraryPathHandler(ILibraryRepository libraryRepository) =>
|
||||
_libraryRepository = libraryRepository;
|
||||
|
||||
public Task<Either<BaseError, LocalLibraryPathViewModel>> Handle(
|
||||
CreateLocalLibraryPath request,
|
||||
@@ -45,7 +45,6 @@ namespace ErsatzTV.Application.Libraries.Commands
|
||||
List<string> allPaths = await _libraryRepository.GetLocalPaths(request.LibraryId)
|
||||
.Map(list => list.Map(c => c.Path).ToList());
|
||||
|
||||
|
||||
return Optional(request.Path)
|
||||
.Filter(folder => allPaths.ForAll(f => !AreSubPaths(f, folder)))
|
||||
.ToValidation<BaseError>("Path must not belong to another library path");
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
using ErsatzTV.Core;
|
||||
using LanguageExt;
|
||||
using MediatR;
|
||||
using Unit = LanguageExt.Unit;
|
||||
|
||||
namespace ErsatzTV.Application.Libraries.Commands
|
||||
{
|
||||
public record DeleteLocalLibrary(int LocalLibraryId) : IRequest<Either<BaseError, Unit>>;
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Data;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Dapper;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Extensions;
|
||||
using LanguageExt;
|
||||
using MediatR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Unit = LanguageExt.Unit;
|
||||
|
||||
namespace ErsatzTV.Application.Libraries.Commands
|
||||
{
|
||||
public class DeleteLocalLibraryHandler : LocalLibraryHandlerBase,
|
||||
IRequestHandler<DeleteLocalLibrary, Either<BaseError, Unit>>
|
||||
{
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||
private readonly IDbConnection _dbConnection;
|
||||
private readonly ISearchIndex _searchIndex;
|
||||
|
||||
public DeleteLocalLibraryHandler(
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
IDbConnection dbConnection,
|
||||
ISearchIndex searchIndex)
|
||||
{
|
||||
_dbContextFactory = dbContextFactory;
|
||||
_dbConnection = dbConnection;
|
||||
_searchIndex = searchIndex;
|
||||
}
|
||||
|
||||
public async Task<Either<BaseError, Unit>> Handle(
|
||||
DeleteLocalLibrary request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||
Validation<BaseError, LocalLibrary> validation = await LocalLibraryMustExist(dbContext, request);
|
||||
return await validation.Apply(localLibrary => DoDeletion(dbContext, localLibrary));
|
||||
}
|
||||
|
||||
private async Task<Unit> DoDeletion(TvContext dbContext, LocalLibrary localLibrary)
|
||||
{
|
||||
List<int> ids = await _dbConnection.QueryAsync<int>(
|
||||
@"SELECT MediaItem.Id FROM MediaItem
|
||||
INNER JOIN LibraryPath LP on MediaItem.LibraryPathId = LP.Id
|
||||
WHERE LP.LibraryId = @LibraryId",
|
||||
new { LibraryId = localLibrary.Id })
|
||||
.Map(result => result.ToList());
|
||||
|
||||
await _searchIndex.RemoveItems(ids);
|
||||
_searchIndex.Commit();
|
||||
|
||||
dbContext.LocalLibraries.Remove(localLibrary);
|
||||
await dbContext.SaveChangesAsync();
|
||||
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
private static Task<Validation<BaseError, LocalLibrary>> LocalLibraryMustExist(
|
||||
TvContext dbContext,
|
||||
DeleteLocalLibrary request) =>
|
||||
dbContext.LocalLibraries
|
||||
.SelectOneAsync(ll => ll.Id, ll => ll.Id == request.LocalLibraryId)
|
||||
.Map(o => o.ToValidation<BaseError>($"Local library {request.LocalLibraryId} does not exist."));
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
using ErsatzTV.Core;
|
||||
using LanguageExt;
|
||||
|
||||
namespace ErsatzTV.Application.Libraries.Commands
|
||||
{
|
||||
public record DeleteLocalLibraryPath(int LocalLibraryPathId) : MediatR.IRequest<Either<BaseError, Unit>>;
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using LanguageExt;
|
||||
|
||||
namespace ErsatzTV.Application.Libraries.Commands
|
||||
{
|
||||
public class
|
||||
DeleteLocalLibraryPathHandler : MediatR.IRequestHandler<DeleteLocalLibraryPath, Either<BaseError, Unit>>
|
||||
{
|
||||
private readonly ILibraryRepository _libraryRepository;
|
||||
private readonly ISearchIndex _searchIndex;
|
||||
|
||||
public DeleteLocalLibraryPathHandler(ILibraryRepository libraryRepository, ISearchIndex searchIndex)
|
||||
{
|
||||
_libraryRepository = libraryRepository;
|
||||
_searchIndex = searchIndex;
|
||||
}
|
||||
|
||||
public Task<Either<BaseError, Unit>> Handle(
|
||||
DeleteLocalLibraryPath request,
|
||||
CancellationToken cancellationToken) =>
|
||||
MediaSourceMustExist(request)
|
||||
.MapT(DoDeletion)
|
||||
.Bind(t => t.ToEitherAsync());
|
||||
|
||||
private async Task<Unit> DoDeletion(LibraryPath libraryPath)
|
||||
{
|
||||
List<int> ids = await _libraryRepository.GetMediaIdsByLocalPath(libraryPath.Id);
|
||||
await _searchIndex.RemoveItems(ids);
|
||||
_searchIndex.Commit();
|
||||
await _libraryRepository.DeleteLocalPath(libraryPath.Id);
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
private async Task<Validation<BaseError, LibraryPath>> MediaSourceMustExist(DeleteLocalLibraryPath request) =>
|
||||
(await _libraryRepository.GetPath(request.LocalLibraryPathId))
|
||||
.HeadOrNone()
|
||||
.ToValidation<BaseError>(
|
||||
$"Local library path {request.LocalLibraryPathId} does not exist.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace ErsatzTV.Application.Libraries.Commands
|
||||
{
|
||||
public interface ILocalLibraryRequest
|
||||
{
|
||||
public string Name { get; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using LanguageExt;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using static LanguageExt.Prelude;
|
||||
|
||||
namespace ErsatzTV.Application.Libraries.Commands
|
||||
{
|
||||
public abstract class LocalLibraryHandlerBase
|
||||
{
|
||||
protected static Task<Validation<BaseError, LocalLibrary>> NameMustBeValid(
|
||||
ILocalLibraryRequest request,
|
||||
LocalLibrary localLibrary) =>
|
||||
request.NotEmpty(c => c.Name)
|
||||
.Bind(_ => request.NotLongerThan(50)(c => c.Name))
|
||||
.Map(_ => localLibrary).AsTask();
|
||||
|
||||
protected static async Task<Validation<BaseError, LocalLibrary>> PathsMustBeValid(
|
||||
TvContext dbContext,
|
||||
LocalLibrary localLibrary,
|
||||
int? existingLibraryId = null)
|
||||
{
|
||||
List<string> allPaths = await dbContext.LocalLibraries
|
||||
.Include(ll => ll.Paths)
|
||||
.Filter(ll => existingLibraryId == null || ll.Id != existingLibraryId)
|
||||
.ToListAsync()
|
||||
.Map(list => list.SelectMany(ll => ll.Paths).Map(lp => lp.Path).ToList());
|
||||
|
||||
return Optional(localLibrary.Paths.Count(folder => allPaths.Any(f => AreSubPaths(f, folder.Path))))
|
||||
.Filter(length => length == 0)
|
||||
.Map(_ => localLibrary)
|
||||
.ToValidation<BaseError>("Path must not belong to another library path");
|
||||
}
|
||||
|
||||
private static bool AreSubPaths(string path1, string path2)
|
||||
{
|
||||
string one = path1 + Path.DirectorySeparatorChar;
|
||||
string two = path2 + Path.DirectorySeparatorChar;
|
||||
return one == two || one.StartsWith(two, StringComparison.OrdinalIgnoreCase) ||
|
||||
two.StartsWith(one, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
using ErsatzTV.Core;
|
||||
using LanguageExt;
|
||||
using MediatR;
|
||||
using Unit = LanguageExt.Unit;
|
||||
|
||||
namespace ErsatzTV.Application.Libraries.Commands
|
||||
{
|
||||
public record MoveLocalLibraryPath(int LibraryPathId, int TargetLibraryId) : IRequest<Either<BaseError, Unit>>;
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Data;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Dapper;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Extensions;
|
||||
using LanguageExt;
|
||||
using MediatR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Unit = LanguageExt.Unit;
|
||||
|
||||
namespace ErsatzTV.Application.Libraries.Commands
|
||||
{
|
||||
public class MoveLocalLibraryPathHandler : IRequestHandler<MoveLocalLibraryPath, Either<BaseError, Unit>>
|
||||
{
|
||||
private readonly ISearchIndex _searchIndex;
|
||||
private readonly ISearchRepository _searchRepository;
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||
private readonly IDbConnection _dbConnection;
|
||||
private readonly ILogger<MoveLocalLibraryPathHandler> _logger;
|
||||
|
||||
public MoveLocalLibraryPathHandler(
|
||||
ISearchIndex searchIndex,
|
||||
ISearchRepository searchRepository,
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
IDbConnection dbConnection,
|
||||
ILogger<MoveLocalLibraryPathHandler> logger)
|
||||
{
|
||||
_searchIndex = searchIndex;
|
||||
_searchRepository = searchRepository;
|
||||
_dbContextFactory = dbContextFactory;
|
||||
_dbConnection = dbConnection;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<Either<BaseError, Unit>> Handle(
|
||||
MoveLocalLibraryPath request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||
Validation<BaseError, Parameters> validation = await Validate(dbContext, request);
|
||||
return await validation.Apply(parameters => MovePath(dbContext, parameters));
|
||||
}
|
||||
|
||||
private async Task<Unit> MovePath(TvContext dbContext, Parameters parameters)
|
||||
{
|
||||
LibraryPath path = parameters.LibraryPath;
|
||||
LocalLibrary newLibrary = parameters.Library;
|
||||
|
||||
path.LibraryId = newLibrary.Id;
|
||||
if (await dbContext.SaveChangesAsync() > 0)
|
||||
{
|
||||
List<int> ids = await _dbConnection.QueryAsync<int>(
|
||||
@"SELECT MediaItem.Id FROM MediaItem WHERE LibraryPathId = @LibraryPathId",
|
||||
new { LibraryPathId = path.Id })
|
||||
.Map(result => result.ToList());
|
||||
|
||||
foreach (int id in ids)
|
||||
{
|
||||
Option<MediaItem> maybeMediaItem = await _searchRepository.GetItemToIndex(id);
|
||||
foreach (MediaItem mediaItem in maybeMediaItem)
|
||||
{
|
||||
_logger.LogInformation("Moving item at {Path}", await GetPath(mediaItem));
|
||||
await _searchIndex.UpdateItems(_searchRepository, new List<MediaItem> { mediaItem });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
private static async Task<Validation<BaseError, Parameters>> Validate(
|
||||
TvContext dbContext,
|
||||
MoveLocalLibraryPath request) =>
|
||||
(await LibraryPathMustExist(dbContext, request), await LocalLibraryMustExist(dbContext, request))
|
||||
.Apply((libraryPath, localLibrary) => new Parameters(libraryPath, localLibrary));
|
||||
|
||||
private static Task<Validation<BaseError, LibraryPath>> LibraryPathMustExist(
|
||||
TvContext dbContext,
|
||||
MoveLocalLibraryPath request) =>
|
||||
dbContext.LibraryPaths
|
||||
.Include(lp => lp.Library)
|
||||
.SelectOneAsync(c => c.Id, c => c.Id == request.LibraryPathId)
|
||||
.Map(o => o.ToValidation<BaseError>("LibraryPath does not exist."));
|
||||
|
||||
private static Task<Validation<BaseError, LocalLibrary>> LocalLibraryMustExist(
|
||||
TvContext dbContext,
|
||||
MoveLocalLibraryPath request) =>
|
||||
dbContext.LocalLibraries
|
||||
.Include(ll => ll.Paths)
|
||||
.SelectOneAsync(a => a.Id, a => a.Id == request.TargetLibraryId)
|
||||
.Map(o => o.ToValidation<BaseError>("LocalLibrary does not exist"));
|
||||
|
||||
private async Task<string> GetPath(MediaItem mediaItem) =>
|
||||
mediaItem switch
|
||||
{
|
||||
Movie => await _dbConnection.QuerySingleAsync<string>(
|
||||
@"SELECT Path FROM MediaFile
|
||||
INNER JOIN MediaVersion MV on MediaFile.MediaVersionId = MV.Id
|
||||
WHERE MV.MovieId = @Id", new { mediaItem.Id }),
|
||||
Episode => await _dbConnection.QuerySingleAsync<string>(
|
||||
@"SELECT Path FROM MediaFile
|
||||
INNER JOIN MediaVersion MV on MediaFile.MediaVersionId = MV.Id
|
||||
WHERE MV.EpisodeId = @Id", new { mediaItem.Id }),
|
||||
MusicVideo => await _dbConnection.QuerySingleAsync<string>(
|
||||
@"SELECT Path FROM MediaFile
|
||||
INNER JOIN MediaVersion MV on MediaFile.MediaVersionId = MV.Id
|
||||
WHERE MV.MusicVideoId = @Id", new { mediaItem.Id }),
|
||||
_ => null
|
||||
};
|
||||
|
||||
private record Parameters(LibraryPath LibraryPath, LocalLibrary Library);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
using System.Collections.Generic;
|
||||
using ErsatzTV.Core;
|
||||
using LanguageExt;
|
||||
using MediatR;
|
||||
|
||||
namespace ErsatzTV.Application.Libraries.Commands
|
||||
{
|
||||
public record UpdateLocalLibraryPath(int Id, string Path);
|
||||
|
||||
public record UpdateLocalLibrary(int Id, string Name, List<UpdateLocalLibraryPath> Paths) : ILocalLibraryRequest,
|
||||
IRequest<Either<BaseError, LocalLibraryViewModel>>;
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Channels;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Application.MediaSources.Commands;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Locking;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Extensions;
|
||||
using LanguageExt;
|
||||
using MediatR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using static ErsatzTV.Application.Libraries.Mapper;
|
||||
|
||||
namespace ErsatzTV.Application.Libraries.Commands
|
||||
{
|
||||
public class UpdateLocalLibraryHandler : LocalLibraryHandlerBase,
|
||||
IRequestHandler<UpdateLocalLibrary, Either<BaseError, LocalLibraryViewModel>>
|
||||
{
|
||||
private readonly ChannelWriter<IBackgroundServiceRequest> _workerChannel;
|
||||
private readonly IEntityLocker _entityLocker;
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||
|
||||
public UpdateLocalLibraryHandler(
|
||||
ChannelWriter<IBackgroundServiceRequest> workerChannel,
|
||||
IEntityLocker entityLocker,
|
||||
IDbContextFactory<TvContext> dbContextFactory)
|
||||
{
|
||||
_workerChannel = workerChannel;
|
||||
_entityLocker = entityLocker;
|
||||
_dbContextFactory = dbContextFactory;
|
||||
}
|
||||
|
||||
public async Task<Either<BaseError, LocalLibraryViewModel>> Handle(
|
||||
UpdateLocalLibrary request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||
Validation<BaseError, Parameters> validation = await Validate(dbContext, request);
|
||||
return await validation.Apply(parameters => UpdateLocalLibrary(dbContext, parameters));
|
||||
}
|
||||
|
||||
private async Task<LocalLibraryViewModel> UpdateLocalLibrary(TvContext dbContext, Parameters parameters)
|
||||
{
|
||||
(LocalLibrary existing, LocalLibrary incoming) = parameters;
|
||||
existing.Name = incoming.Name;
|
||||
|
||||
// toAdd
|
||||
var toAdd = incoming.Paths
|
||||
.Filter(p => existing.Paths.All(ep => NormalizePath(ep.Path) != NormalizePath(p.Path)))
|
||||
.ToList();
|
||||
var toRemove = existing.Paths
|
||||
.Filter(ep => incoming.Paths.All(p => NormalizePath(p.Path) != NormalizePath(ep.Path)))
|
||||
.ToList();
|
||||
|
||||
existing.Paths.RemoveAll(toRemove.Contains);
|
||||
existing.Paths.AddRange(toAdd);
|
||||
|
||||
await dbContext.SaveChangesAsync();
|
||||
|
||||
if (toAdd.Count > 0 || toRemove.Count > 0 && _entityLocker.LockLibrary(existing.Id))
|
||||
{
|
||||
await _workerChannel.WriteAsync(new ForceScanLocalLibrary(existing.Id));
|
||||
}
|
||||
|
||||
return ProjectToViewModel(existing);
|
||||
}
|
||||
|
||||
private static Task<Validation<BaseError, Parameters>> Validate(
|
||||
TvContext dbContext,
|
||||
UpdateLocalLibrary request) =>
|
||||
LocalLibraryMustExist(dbContext, request)
|
||||
.BindT(parameters => NameMustBeValid(request, parameters.Incoming).MapT(_ => parameters))
|
||||
.BindT(
|
||||
parameters => PathsMustBeValid(dbContext, parameters.Incoming, parameters.Existing.Id)
|
||||
.MapT(_ => parameters));
|
||||
|
||||
private static Task<Validation<BaseError, Parameters>> LocalLibraryMustExist(
|
||||
TvContext dbContext,
|
||||
UpdateLocalLibrary request) =>
|
||||
dbContext.LocalLibraries
|
||||
.Include(ll => ll.Paths)
|
||||
.SelectOneAsync(ll => ll.Id, ll => ll.Id == request.Id)
|
||||
.MapT(
|
||||
existing =>
|
||||
{
|
||||
var incoming = new LocalLibrary
|
||||
{
|
||||
Name = request.Name,
|
||||
Paths = request.Paths.Map(p => new LibraryPath { Id = p.Id, Path = p.Path }).ToList(),
|
||||
MediaSourceId = existing.Id
|
||||
};
|
||||
|
||||
return new Parameters(existing, incoming);
|
||||
})
|
||||
.Map(o => o.ToValidation<BaseError>("LocalLibrary does not exist."));
|
||||
|
||||
private record Parameters(LocalLibrary Existing, LocalLibrary Incoming);
|
||||
|
||||
private static string NormalizePath(string path)
|
||||
{
|
||||
return Path.GetFullPath(new Uri(path).LocalPath)
|
||||
.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)
|
||||
.ToUpperInvariant();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
using MediatR;
|
||||
|
||||
namespace ErsatzTV.Application.Libraries.Queries
|
||||
{
|
||||
public record CountMediaItemsByLibrary(int LibraryId) : IRequest<int>;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
using System.Data;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Dapper;
|
||||
using MediatR;
|
||||
|
||||
namespace ErsatzTV.Application.Libraries.Queries
|
||||
{
|
||||
public class CountMediaItemsByLibraryHandler : IRequestHandler<CountMediaItemsByLibrary, int>
|
||||
{
|
||||
private readonly IDbConnection _dbConnection;
|
||||
|
||||
public CountMediaItemsByLibraryHandler(IDbConnection dbConnection)
|
||||
{
|
||||
_dbConnection = dbConnection;
|
||||
}
|
||||
|
||||
public Task<int> Handle(CountMediaItemsByLibrary request, CancellationToken cancellationToken) =>
|
||||
_dbConnection.QuerySingleAsync<int>(
|
||||
@"SELECT COUNT(*) FROM MediaItem
|
||||
INNER JOIN LibraryPath LP on MediaItem.LibraryPathId = LP.Id
|
||||
WHERE LP.LibraryId = @LibraryId",
|
||||
new { request.LibraryId });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
using System.Collections.Generic;
|
||||
using MediatR;
|
||||
|
||||
namespace ErsatzTV.Application.Libraries.Queries
|
||||
{
|
||||
public record GetAllLocalLibraries : IRequest<List<LocalLibraryViewModel>>;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using LanguageExt;
|
||||
using MediatR;
|
||||
using static ErsatzTV.Application.Libraries.Mapper;
|
||||
|
||||
namespace ErsatzTV.Application.Libraries.Queries
|
||||
{
|
||||
public class GetAllLocalLibrariesHandler : IRequestHandler<GetAllLocalLibraries, List<LocalLibraryViewModel>>
|
||||
{
|
||||
private readonly ILibraryRepository _libraryRepository;
|
||||
|
||||
public GetAllLocalLibrariesHandler(ILibraryRepository libraryRepository) => _libraryRepository = libraryRepository;
|
||||
|
||||
public Task<List<LocalLibraryViewModel>> Handle(
|
||||
GetAllLocalLibraries request,
|
||||
CancellationToken cancellationToken) =>
|
||||
_libraryRepository.GetAll()
|
||||
.Map(
|
||||
list => list
|
||||
.OfType<LocalLibrary>()
|
||||
.OrderBy(l => l.MediaKind)
|
||||
.Map(ProjectToViewModel)
|
||||
.ToList());
|
||||
}
|
||||
}
|
||||
@@ -123,12 +123,12 @@ namespace ErsatzTV.Application.MediaCards
|
||||
|
||||
if (maybeJellyfin.IsSome && artwork.StartsWith("jellyfin://"))
|
||||
{
|
||||
artwork = JellyfinUrl.ForArtwork(maybeJellyfin, artwork)
|
||||
artwork = JellyfinUrl.RelativeProxyForArtwork(artwork)
|
||||
.SetQueryParam("fillHeight", 440);
|
||||
}
|
||||
else if (maybeEmby.IsSome && artwork.StartsWith("emby://"))
|
||||
{
|
||||
artwork = EmbyUrl.ForArtwork(maybeEmby, artwork)
|
||||
artwork = EmbyUrl.RelativeProxyForArtwork(artwork)
|
||||
.SetQueryParam("maxHeight", 440);
|
||||
}
|
||||
|
||||
@@ -171,12 +171,12 @@ namespace ErsatzTV.Application.MediaCards
|
||||
|
||||
if (maybeJellyfin.IsSome && poster.StartsWith("jellyfin://"))
|
||||
{
|
||||
poster = JellyfinUrl.ForArtwork(maybeJellyfin, poster)
|
||||
poster = JellyfinUrl.RelativeProxyForArtwork(poster)
|
||||
.SetQueryParam("fillHeight", 440);
|
||||
}
|
||||
else if (maybeEmby.IsSome && poster.StartsWith("emby://"))
|
||||
{
|
||||
poster = EmbyUrl.ForArtwork(maybeEmby, poster)
|
||||
poster = EmbyUrl.RelativeProxyForArtwork(poster)
|
||||
.SetQueryParam("maxHeight", 440);
|
||||
}
|
||||
|
||||
@@ -193,12 +193,12 @@ namespace ErsatzTV.Application.MediaCards
|
||||
|
||||
if (maybeJellyfin.IsSome && thumb.StartsWith("jellyfin://"))
|
||||
{
|
||||
thumb = JellyfinUrl.ForArtwork(maybeJellyfin, thumb)
|
||||
thumb = JellyfinUrl.RelativeProxyForArtwork(thumb)
|
||||
.SetQueryParam("fillHeight", 220);
|
||||
}
|
||||
else if (maybeEmby.IsSome && thumb.StartsWith("emby://"))
|
||||
{
|
||||
thumb = EmbyUrl.ForArtwork(maybeEmby, thumb)
|
||||
thumb = EmbyUrl.RelativeProxyForArtwork(thumb)
|
||||
.SetQueryParam("maxHeight", 220);
|
||||
}
|
||||
|
||||
|
||||
@@ -73,7 +73,7 @@ namespace ErsatzTV.Application.MediaCollections.Commands
|
||||
AddArtistToCollection request) =>
|
||||
dbContext.Artists
|
||||
.SelectOneAsync(a => a.Id, a => a.Id == request.ArtistId)
|
||||
.Map(o => o.ToValidation<BaseError>("Music video does not exist"));
|
||||
.Map(o => o.ToValidation<BaseError>("Artist does not exist"));
|
||||
|
||||
private record Parameters(Collection Collection, Artist Artist);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
using System.Collections.Generic;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using LanguageExt;
|
||||
using MediatR;
|
||||
|
||||
namespace ErsatzTV.Application.MediaCollections.Commands
|
||||
{
|
||||
public record CreateMultiCollectionItem(int CollectionId, bool ScheduleAsGroup, PlaybackOrder PlaybackOrder);
|
||||
|
||||
public record CreateMultiCollection
|
||||
(string Name, List<CreateMultiCollectionItem> Items) : IRequest<Either<BaseError, MultiCollectionViewModel>>;
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
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 CreateMultiCollectionHandler :
|
||||
IRequestHandler<CreateMultiCollection, Either<BaseError, MultiCollectionViewModel>>
|
||||
{
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||
|
||||
public CreateMultiCollectionHandler(IDbContextFactory<TvContext> dbContextFactory) =>
|
||||
_dbContextFactory = dbContextFactory;
|
||||
|
||||
public async Task<Either<BaseError, MultiCollectionViewModel>> Handle(
|
||||
CreateMultiCollection request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||
Validation<BaseError, MultiCollection> validation = await Validate(dbContext, request);
|
||||
return await validation.Apply(c => PersistCollection(dbContext, c));
|
||||
}
|
||||
|
||||
private static async Task<MultiCollectionViewModel> PersistCollection(
|
||||
TvContext dbContext,
|
||||
MultiCollection multiCollection)
|
||||
{
|
||||
await dbContext.MultiCollections.AddAsync(multiCollection);
|
||||
await dbContext.SaveChangesAsync();
|
||||
await dbContext.Entry(multiCollection)
|
||||
.Collection(c => c.MultiCollectionItems)
|
||||
.Query()
|
||||
.Include(i => i.Collection)
|
||||
.LoadAsync();
|
||||
return ProjectToViewModel(multiCollection);
|
||||
}
|
||||
|
||||
private static Task<Validation<BaseError, MultiCollection>> Validate(
|
||||
TvContext dbContext,
|
||||
CreateMultiCollection request) =>
|
||||
ValidateName(dbContext, request).MapT(
|
||||
name => new MultiCollection
|
||||
{
|
||||
Name = name,
|
||||
MultiCollectionItems = request.Items.Map(i => new MultiCollectionItem
|
||||
{
|
||||
CollectionId = i.CollectionId,
|
||||
ScheduleAsGroup = i.ScheduleAsGroup,
|
||||
PlaybackOrder = i.PlaybackOrder
|
||||
}).ToList()
|
||||
});
|
||||
|
||||
private static async Task<Validation<BaseError, string>> ValidateName(
|
||||
TvContext dbContext,
|
||||
CreateMultiCollection createMultiCollection)
|
||||
{
|
||||
List<string> allNames = await dbContext.MultiCollections
|
||||
.Map(c => c.Name)
|
||||
.ToListAsync();
|
||||
|
||||
Validation<BaseError, string> result1 = createMultiCollection.NotEmpty(c => c.Name)
|
||||
.Bind(_ => createMultiCollection.NotLongerThan(50)(c => c.Name));
|
||||
|
||||
var result2 = Optional(createMultiCollection.Name)
|
||||
.Filter(name => !allNames.Contains(name))
|
||||
.ToValidation<BaseError>("MultiCollection name must be unique");
|
||||
|
||||
return (result1, result2).Apply((_, _) => createMultiCollection.Name);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
using ErsatzTV.Core;
|
||||
using LanguageExt;
|
||||
|
||||
namespace ErsatzTV.Application.MediaCollections.Commands
|
||||
{
|
||||
public record DeleteMultiCollection(int MultiCollectionId) : MediatR.IRequest<Either<BaseError, Unit>>;
|
||||
}
|
||||
@@ -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 DeleteMultiCollectionHandler : MediatR.IRequestHandler<DeleteMultiCollection, Either<BaseError, Unit>>
|
||||
{
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||
|
||||
public DeleteMultiCollectionHandler(IDbContextFactory<TvContext> dbContextFactory) =>
|
||||
_dbContextFactory = dbContextFactory;
|
||||
|
||||
public async Task<Either<BaseError, Unit>> Handle(
|
||||
DeleteMultiCollection request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||
|
||||
Validation<BaseError, MultiCollection> validation = await MultiCollectionMustExist(dbContext, request);
|
||||
return await validation.Apply(c => DoDeletion(dbContext, c));
|
||||
}
|
||||
|
||||
private static Task<Unit> DoDeletion(TvContext dbContext, MultiCollection multiCollection)
|
||||
{
|
||||
dbContext.MultiCollections.Remove(multiCollection);
|
||||
return dbContext.SaveChangesAsync().ToUnit();
|
||||
}
|
||||
|
||||
private static Task<Validation<BaseError, MultiCollection>> MultiCollectionMustExist(
|
||||
TvContext dbContext,
|
||||
DeleteMultiCollection request) =>
|
||||
dbContext.MultiCollections
|
||||
.SelectOneAsync(c => c.Id, c => c.Id == request.MultiCollectionId)
|
||||
.Map(o => o.ToValidation<BaseError>($"MultiCollection {request.MultiCollectionId} does not exist."));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
using System.Collections.Generic;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using LanguageExt;
|
||||
|
||||
namespace ErsatzTV.Application.MediaCollections.Commands
|
||||
{
|
||||
public record UpdateMultiCollectionItem(int CollectionId, bool ScheduleAsGroup, PlaybackOrder PlaybackOrder);
|
||||
|
||||
public record UpdateMultiCollection
|
||||
(
|
||||
int MultiCollectionId,
|
||||
string Name,
|
||||
List<UpdateMultiCollectionItem> Items) : MediatR.IRequest<Either<BaseError, Unit>>;
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
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 UpdateMultiCollectionHandler : MediatR.IRequestHandler<UpdateMultiCollection, Either<BaseError, Unit>>
|
||||
{
|
||||
private readonly ChannelWriter<IBackgroundServiceRequest> _channel;
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||
private readonly IMediaCollectionRepository _mediaCollectionRepository;
|
||||
|
||||
public UpdateMultiCollectionHandler(
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
IMediaCollectionRepository mediaCollectionRepository,
|
||||
ChannelWriter<IBackgroundServiceRequest> channel)
|
||||
{
|
||||
_dbContextFactory = dbContextFactory;
|
||||
_mediaCollectionRepository = mediaCollectionRepository;
|
||||
_channel = channel;
|
||||
}
|
||||
|
||||
public async Task<Either<BaseError, Unit>> Handle(
|
||||
UpdateMultiCollection request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||
Validation<BaseError, MultiCollection> validation = await Validate(dbContext, request);
|
||||
return await validation.Apply(c => ApplyUpdateRequest(dbContext, c, request));
|
||||
}
|
||||
|
||||
private async Task<Unit> ApplyUpdateRequest(TvContext dbContext, MultiCollection c, UpdateMultiCollection request)
|
||||
{
|
||||
c.Name = request.Name;
|
||||
|
||||
// save name first so playouts don't get rebuild 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
|
||||
{
|
||||
CollectionId = i.CollectionId,
|
||||
MultiCollectionId = c.Id,
|
||||
ScheduleAsGroup = i.ScheduleAsGroup,
|
||||
PlaybackOrder = i.PlaybackOrder
|
||||
})
|
||||
.ToList();
|
||||
var toRemove = c.MultiCollectionItems
|
||||
.Filter(i => request.Items.All(i2 => i2.CollectionId != i.CollectionId))
|
||||
.ToList();
|
||||
|
||||
// remove items that are no longer present
|
||||
c.MultiCollectionItems.RemoveAll(toRemove.Contains);
|
||||
|
||||
// update existing items
|
||||
foreach (MultiCollectionItem item in c.MultiCollectionItems)
|
||||
{
|
||||
foreach (UpdateMultiCollectionItem incoming in request.Items.Filter(
|
||||
i => i.CollectionId == item.CollectionId))
|
||||
{
|
||||
item.ScheduleAsGroup = incoming.ScheduleAsGroup;
|
||||
item.PlaybackOrder = incoming.PlaybackOrder;
|
||||
}
|
||||
}
|
||||
|
||||
// add new items
|
||||
c.MultiCollectionItems.AddRange(toAdd);
|
||||
|
||||
// rebuild playouts
|
||||
if (await dbContext.SaveChangesAsync() > 0)
|
||||
{
|
||||
// rebuild all playouts that use this collection
|
||||
foreach (int playoutId in await _mediaCollectionRepository.PlayoutIdsUsingMultiCollection(
|
||||
request.MultiCollectionId))
|
||||
{
|
||||
await _channel.WriteAsync(new BuildPlayout(playoutId, true));
|
||||
}
|
||||
}
|
||||
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
private static async Task<Validation<BaseError, MultiCollection>> Validate(
|
||||
TvContext dbContext,
|
||||
UpdateMultiCollection request) =>
|
||||
(await MultiCollectionMustExist(dbContext, request), await ValidateName(dbContext, request))
|
||||
.Apply((collectionToUpdate, _) => collectionToUpdate);
|
||||
|
||||
private static Task<Validation<BaseError, MultiCollection>> MultiCollectionMustExist(
|
||||
TvContext dbContext,
|
||||
UpdateMultiCollection updateCollection) =>
|
||||
dbContext.MultiCollections
|
||||
.Include(mc => mc.MultiCollectionItems)
|
||||
.SelectOneAsync(c => c.Id, c => c.Id == updateCollection.MultiCollectionId)
|
||||
.Map(o => o.ToValidation<BaseError>("MultiCollection does not exist."));
|
||||
|
||||
private static async Task<Validation<BaseError, string>> ValidateName(TvContext dbContext, UpdateMultiCollection updateMultiCollection)
|
||||
{
|
||||
List<string> allNames = await dbContext.MultiCollections
|
||||
.Filter(mc => mc.Id != updateMultiCollection.MultiCollectionId)
|
||||
.Map(c => c.Name)
|
||||
.ToListAsync();
|
||||
|
||||
Validation<BaseError, string> result1 = updateMultiCollection.NotEmpty(c => c.Name)
|
||||
.Bind(_ => updateMultiCollection.NotLongerThan(50)(c => c.Name));
|
||||
|
||||
var result2 = Optional(updateMultiCollection.Name)
|
||||
.Filter(name => !allNames.Contains(name))
|
||||
.ToValidation<BaseError>("MultiCollection name must be unique");
|
||||
|
||||
return (result1, result2).Apply((_, _) => updateMultiCollection.Name);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,25 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using System.Linq;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using static LanguageExt.Prelude;
|
||||
|
||||
namespace ErsatzTV.Application.MediaCollections
|
||||
{
|
||||
internal static class Mapper
|
||||
{
|
||||
internal static MediaCollectionViewModel ProjectToViewModel(Collection collection) =>
|
||||
new(collection.Id, collection.Name);
|
||||
new(collection.Id, collection.Name, collection.UseCustomPlaybackOrder);
|
||||
|
||||
internal static MultiCollectionViewModel ProjectToViewModel(MultiCollection multiCollection) =>
|
||||
new(
|
||||
multiCollection.Id,
|
||||
multiCollection.Name,
|
||||
Optional(multiCollection.MultiCollectionItems).Flatten().Map(ProjectToViewModel).ToList());
|
||||
|
||||
private static MultiCollectionItemViewModel ProjectToViewModel(MultiCollectionItem multiCollectionItem) =>
|
||||
new(
|
||||
multiCollectionItem.MultiCollectionId,
|
||||
ProjectToViewModel(multiCollectionItem.Collection),
|
||||
multiCollectionItem.ScheduleAsGroup,
|
||||
multiCollectionItem.PlaybackOrder);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
namespace ErsatzTV.Application.MediaCollections
|
||||
{
|
||||
public record MediaCollectionViewModel(int Id, string Name) : MediaCardViewModel(
|
||||
public record MediaCollectionViewModel(int Id, string Name, bool UseCustomPlaybackOrder) : MediaCardViewModel(
|
||||
Id,
|
||||
Name,
|
||||
string.Empty,
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
|
||||
namespace ErsatzTV.Application.MediaCollections
|
||||
{
|
||||
public record MultiCollectionItemViewModel(
|
||||
int MultiCollectionId,
|
||||
MediaCollectionViewModel Collection,
|
||||
bool ScheduleAsGroup,
|
||||
PlaybackOrder PlaybackOrder);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace ErsatzTV.Application.MediaCollections
|
||||
{
|
||||
public record MultiCollectionViewModel(int Id, string Name, List<MultiCollectionItemViewModel> Items);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace ErsatzTV.Application.MediaCollections
|
||||
{
|
||||
public record PagedMultiCollectionsViewModel(int TotalCount, List<MultiCollectionViewModel> Page);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
using System.Collections.Generic;
|
||||
using MediatR;
|
||||
|
||||
namespace ErsatzTV.Application.MediaCollections.Queries
|
||||
{
|
||||
public record GetAllMultiCollections : IRequest<List<MultiCollectionViewModel>>;
|
||||
}
|
||||
@@ -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 GetAllMultiCollectionsHandler : IRequestHandler<GetAllMultiCollections, List<MultiCollectionViewModel>>
|
||||
{
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||
|
||||
public GetAllMultiCollectionsHandler(IDbContextFactory<TvContext> dbContextFactory) =>
|
||||
_dbContextFactory = dbContextFactory;
|
||||
|
||||
public async Task<List<MultiCollectionViewModel>> Handle(
|
||||
GetAllMultiCollections request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||
return await dbContext.MultiCollections
|
||||
.ToListAsync(cancellationToken)
|
||||
.Map(list => list.Map(ProjectToViewModel).ToList());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
using LanguageExt;
|
||||
using MediatR;
|
||||
|
||||
namespace ErsatzTV.Application.MediaCollections.Queries
|
||||
{
|
||||
public record GetMultiCollectionById(int Id) : IRequest<Option<MultiCollectionViewModel>>;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Extensions;
|
||||
using LanguageExt;
|
||||
using MediatR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using static ErsatzTV.Application.MediaCollections.Mapper;
|
||||
|
||||
namespace ErsatzTV.Application.MediaCollections.Queries
|
||||
{
|
||||
public class GetMultiCollectionByIdHandler : IRequestHandler<GetMultiCollectionById, Option<MultiCollectionViewModel>>
|
||||
{
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||
|
||||
public GetMultiCollectionByIdHandler(IDbContextFactory<TvContext> dbContextFactory) =>
|
||||
_dbContextFactory = dbContextFactory;
|
||||
|
||||
public async Task<Option<MultiCollectionViewModel>> Handle(
|
||||
GetMultiCollectionById request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||
return await dbContext.MultiCollections
|
||||
.Include(mc => mc.MultiCollectionItems)
|
||||
.ThenInclude(mc => mc.Collection)
|
||||
.SelectOneAsync(c => c.Id, c => c.Id == request.Id)
|
||||
.MapT(ProjectToViewModel);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
using MediatR;
|
||||
|
||||
namespace ErsatzTV.Application.MediaCollections.Queries
|
||||
{
|
||||
public record GetPagedMultiCollections(int PageNum, int PageSize) : IRequest<PagedMultiCollectionsViewModel>;
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
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 GetPagedMultiCollectionsHandler : IRequestHandler<GetPagedMultiCollections, PagedMultiCollectionsViewModel>
|
||||
{
|
||||
private readonly IDbConnection _dbConnection;
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||
|
||||
public GetPagedMultiCollectionsHandler(IDbContextFactory<TvContext> dbContextFactory, IDbConnection dbConnection)
|
||||
{
|
||||
_dbContextFactory = dbContextFactory;
|
||||
_dbConnection = dbConnection;
|
||||
}
|
||||
|
||||
public async Task<PagedMultiCollectionsViewModel> Handle(
|
||||
GetPagedMultiCollections request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
int count = await _dbConnection.QuerySingleAsync<int>(@"SELECT COUNT (*) FROM Collection");
|
||||
|
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||
List<MultiCollectionViewModel> page = await dbContext.MultiCollections.FromSqlRaw(
|
||||
@"SELECT * FROM MultiCollection
|
||||
ORDER BY Name
|
||||
COLLATE NOCASE
|
||||
LIMIT {0} OFFSET {1}",
|
||||
request.PageSize,
|
||||
request.PageNum * request.PageSize)
|
||||
.Include(mc => mc.MultiCollectionItems)
|
||||
.ThenInclude(i => i.Collection)
|
||||
.ToListAsync(cancellationToken)
|
||||
.Map(list => list.Map(ProjectToViewModel).ToList());
|
||||
|
||||
return new PagedMultiCollectionsViewModel(count, page);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -66,7 +66,7 @@ namespace ErsatzTV.Application.Movies
|
||||
|
||||
if (maybeJellyfin.IsSome && artwork.StartsWith("jellyfin://"))
|
||||
{
|
||||
Url url = JellyfinUrl.ForArtwork(maybeJellyfin, artwork);
|
||||
Url url = JellyfinUrl.RelativeProxyForArtwork(artwork);
|
||||
if (artworkKind is ArtworkKind.Poster or ArtworkKind.Thumbnail)
|
||||
{
|
||||
url.SetQueryParam("fillHeight", 440);
|
||||
@@ -76,7 +76,7 @@ namespace ErsatzTV.Application.Movies
|
||||
}
|
||||
else if (maybeEmby.IsSome && artwork.StartsWith("emby://"))
|
||||
{
|
||||
Url url = EmbyUrl.ForArtwork(maybeEmby, artwork);
|
||||
Url url = EmbyUrl.RelativeProxyForArtwork(artwork);
|
||||
if (artworkKind is ArtworkKind.Poster or ArtworkKind.Thumbnail)
|
||||
{
|
||||
url.SetQueryParam("maxHeight", 440);
|
||||
|
||||
@@ -13,7 +13,9 @@ namespace ErsatzTV.Application.ProgramSchedules.Commands
|
||||
PlayoutMode PlayoutMode,
|
||||
ProgramScheduleItemCollectionType CollectionType,
|
||||
int? CollectionId,
|
||||
int? MultiCollectionId,
|
||||
int? MediaItemId,
|
||||
PlaybackOrder PlaybackOrder,
|
||||
int? MultipleCount,
|
||||
TimeSpan? PlayoutDuration,
|
||||
bool? OfflineTail,
|
||||
|
||||
@@ -7,7 +7,6 @@ namespace ErsatzTV.Application.ProgramSchedules.Commands
|
||||
{
|
||||
public record CreateProgramSchedule(
|
||||
string Name,
|
||||
PlaybackOrder MediaCollectionPlaybackOrder,
|
||||
bool KeepMultiPartEpisodesTogether,
|
||||
bool TreatCollectionsAsShows) : IRequest<Either<BaseError, CreateProgramScheduleResult>>;
|
||||
}
|
||||
|
||||
@@ -45,13 +45,10 @@ namespace ErsatzTV.Application.ProgramSchedules.Commands
|
||||
ValidateName(dbContext, request),
|
||||
name =>
|
||||
{
|
||||
bool keepMultiPartEpisodesTogether =
|
||||
request.MediaCollectionPlaybackOrder == PlaybackOrder.Shuffle &&
|
||||
request.KeepMultiPartEpisodesTogether;
|
||||
bool keepMultiPartEpisodesTogether = request.KeepMultiPartEpisodesTogether;
|
||||
return new ProgramSchedule
|
||||
{
|
||||
Name = name,
|
||||
MediaCollectionPlaybackOrder = request.MediaCollectionPlaybackOrder,
|
||||
KeepMultiPartEpisodesTogether = keepMultiPartEpisodesTogether,
|
||||
TreatCollectionsAsShows = keepMultiPartEpisodesTogether && request.TreatCollectionsAsShows
|
||||
};
|
||||
|
||||
@@ -8,8 +8,10 @@ namespace ErsatzTV.Application.ProgramSchedules.Commands
|
||||
TimeSpan? StartTime { get; }
|
||||
ProgramScheduleItemCollectionType CollectionType { get; }
|
||||
int? CollectionId { get; }
|
||||
int? MultiCollectionId { get; }
|
||||
int? MediaItemId { get; }
|
||||
PlayoutMode PlayoutMode { get; }
|
||||
PlaybackOrder PlaybackOrder { get; }
|
||||
int? MultipleCount { get; }
|
||||
TimeSpan? PlayoutDuration { get; }
|
||||
bool? OfflineTail { get; }
|
||||
|
||||
@@ -24,6 +24,23 @@ 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;
|
||||
}
|
||||
}
|
||||
else if (item.PlaybackOrder == PlaybackOrder.ShuffleInOrder)
|
||||
{
|
||||
return BaseError.New("Invalid playback order: 'Shuffle In Order'");
|
||||
}
|
||||
|
||||
switch (item.PlayoutMode)
|
||||
{
|
||||
case PlayoutMode.Flood:
|
||||
@@ -88,6 +105,13 @@ namespace ErsatzTV.Application.ProgramSchedules.Commands
|
||||
return BaseError.New("[MediaItem] is required for collection type 'Artist'");
|
||||
}
|
||||
|
||||
break;
|
||||
case ProgramScheduleItemCollectionType.MultiCollection:
|
||||
if (item.MultiCollectionId is null)
|
||||
{
|
||||
return BaseError.New("[MultiCollection] is required for collection type 'MultiCollection'");
|
||||
}
|
||||
|
||||
break;
|
||||
default:
|
||||
return BaseError.New("[CollectionType] is invalid");
|
||||
@@ -109,7 +133,9 @@ namespace ErsatzTV.Application.ProgramSchedules.Commands
|
||||
StartTime = item.StartTime,
|
||||
CollectionType = item.CollectionType,
|
||||
CollectionId = item.CollectionId,
|
||||
MultiCollectionId = item.MultiCollectionId,
|
||||
MediaItemId = item.MediaItemId,
|
||||
PlaybackOrder = item.PlaybackOrder,
|
||||
CustomTitle = item.CustomTitle
|
||||
},
|
||||
PlayoutMode.One => new ProgramScheduleItemOne
|
||||
@@ -119,7 +145,9 @@ namespace ErsatzTV.Application.ProgramSchedules.Commands
|
||||
StartTime = item.StartTime,
|
||||
CollectionType = item.CollectionType,
|
||||
CollectionId = item.CollectionId,
|
||||
MultiCollectionId = item.MultiCollectionId,
|
||||
MediaItemId = item.MediaItemId,
|
||||
PlaybackOrder = item.PlaybackOrder,
|
||||
CustomTitle = item.CustomTitle
|
||||
},
|
||||
PlayoutMode.Multiple => new ProgramScheduleItemMultiple
|
||||
@@ -129,7 +157,9 @@ namespace ErsatzTV.Application.ProgramSchedules.Commands
|
||||
StartTime = item.StartTime,
|
||||
CollectionType = item.CollectionType,
|
||||
CollectionId = item.CollectionId,
|
||||
MultiCollectionId = item.MultiCollectionId,
|
||||
MediaItemId = item.MediaItemId,
|
||||
PlaybackOrder = item.PlaybackOrder,
|
||||
Count = item.MultipleCount.GetValueOrDefault(),
|
||||
CustomTitle = item.CustomTitle
|
||||
},
|
||||
@@ -140,7 +170,9 @@ namespace ErsatzTV.Application.ProgramSchedules.Commands
|
||||
StartTime = item.StartTime,
|
||||
CollectionType = item.CollectionType,
|
||||
CollectionId = item.CollectionId,
|
||||
MultiCollectionId = item.MultiCollectionId,
|
||||
MediaItemId = item.MediaItemId,
|
||||
PlaybackOrder = item.PlaybackOrder,
|
||||
PlayoutDuration = FixDuration(item.PlayoutDuration.GetValueOrDefault()),
|
||||
OfflineTail = item.OfflineTail.GetValueOrDefault(),
|
||||
CustomTitle = item.CustomTitle
|
||||
|
||||
@@ -14,7 +14,9 @@ namespace ErsatzTV.Application.ProgramSchedules.Commands
|
||||
PlayoutMode PlayoutMode,
|
||||
ProgramScheduleItemCollectionType CollectionType,
|
||||
int? CollectionId,
|
||||
int? MultiCollectionId,
|
||||
int? MediaItemId,
|
||||
PlaybackOrder PlaybackOrder,
|
||||
int? MultipleCount,
|
||||
TimeSpan? PlayoutDuration,
|
||||
bool? OfflineTail,
|
||||
|
||||
+39
-1
@@ -11,6 +11,7 @@ using LanguageExt;
|
||||
using MediatR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using static ErsatzTV.Application.ProgramSchedules.Mapper;
|
||||
using static LanguageExt.Prelude;
|
||||
|
||||
namespace ErsatzTV.Application.ProgramSchedules.Commands
|
||||
{
|
||||
@@ -61,7 +62,8 @@ namespace ErsatzTV.Application.ProgramSchedules.Commands
|
||||
ReplaceProgramScheduleItems request) =>
|
||||
ProgramScheduleMustExist(dbContext, request.ProgramScheduleId)
|
||||
.BindT(programSchedule => PlayoutModesMustBeValid(request, programSchedule))
|
||||
.BindT(programSchedule => CollectionTypesMustBeValid(request, programSchedule));
|
||||
.BindT(programSchedule => CollectionTypesMustBeValid(request, programSchedule))
|
||||
.BindT(programSchedule => PlaybackOrdersMustBeValid(request, programSchedule));
|
||||
|
||||
private static Validation<BaseError, ProgramSchedule> PlayoutModesMustBeValid(
|
||||
ReplaceProgramScheduleItems request,
|
||||
@@ -74,5 +76,41 @@ namespace ErsatzTV.Application.ProgramSchedules.Commands
|
||||
ProgramSchedule programSchedule) =>
|
||||
request.Items.Map(item => CollectionTypeMustBeValid(item, programSchedule)).Sequence()
|
||||
.Map(_ => programSchedule);
|
||||
|
||||
private static Validation<BaseError, ProgramSchedule> PlaybackOrdersMustBeValid(
|
||||
ReplaceProgramScheduleItems request,
|
||||
ProgramSchedule programSchedule)
|
||||
{
|
||||
var keyOrders = new Dictionary<CollectionKey, System.Collections.Generic.HashSet<PlaybackOrder>>();
|
||||
foreach (ReplaceProgramScheduleItem item in request.Items)
|
||||
{
|
||||
var key = new CollectionKey(
|
||||
item.CollectionType,
|
||||
item.CollectionId,
|
||||
item.MediaItemId,
|
||||
item.MultiCollectionId);
|
||||
|
||||
if (keyOrders.TryGetValue(key, out System.Collections.Generic.HashSet<PlaybackOrder> playbackOrders))
|
||||
{
|
||||
playbackOrders.Add(item.PlaybackOrder);
|
||||
keyOrders[key] = playbackOrders;
|
||||
}
|
||||
else
|
||||
{
|
||||
keyOrders.Add(key, new System.Collections.Generic.HashSet<PlaybackOrder> { item.PlaybackOrder });
|
||||
}
|
||||
}
|
||||
|
||||
return Optional(keyOrders.Values.Count(set => set.Count != 1))
|
||||
.Filter(count => count == 0)
|
||||
.Map(_ => programSchedule)
|
||||
.ToValidation<BaseError>("A collection must not use multiple playback orders");
|
||||
}
|
||||
|
||||
private record CollectionKey(
|
||||
ProgramScheduleItemCollectionType CollectionType,
|
||||
int? CollectionId,
|
||||
int? MediaItemId,
|
||||
int? MultiCollectionId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ namespace ErsatzTV.Application.ProgramSchedules.Commands
|
||||
(
|
||||
int ProgramScheduleId,
|
||||
string Name,
|
||||
PlaybackOrder MediaCollectionPlaybackOrder,
|
||||
bool KeepMultiPartEpisodesTogether,
|
||||
bool TreatCollectionsAsShows) : IRequest<Either<BaseError, UpdateProgramScheduleResult>>;
|
||||
}
|
||||
|
||||
@@ -44,15 +44,11 @@ namespace ErsatzTV.Application.ProgramSchedules.Commands
|
||||
{
|
||||
// we need to rebuild playouts if the playback order or keep multi-episodes has been modified
|
||||
bool needToRebuildPlayout =
|
||||
programSchedule.MediaCollectionPlaybackOrder != request.MediaCollectionPlaybackOrder ||
|
||||
programSchedule.KeepMultiPartEpisodesTogether != request.KeepMultiPartEpisodesTogether ||
|
||||
programSchedule.TreatCollectionsAsShows != request.TreatCollectionsAsShows;
|
||||
|
||||
programSchedule.Name = request.Name;
|
||||
programSchedule.MediaCollectionPlaybackOrder = request.MediaCollectionPlaybackOrder;
|
||||
programSchedule.KeepMultiPartEpisodesTogether =
|
||||
request.MediaCollectionPlaybackOrder == PlaybackOrder.Shuffle &&
|
||||
request.KeepMultiPartEpisodesTogether;
|
||||
programSchedule.KeepMultiPartEpisodesTogether = request.KeepMultiPartEpisodesTogether;
|
||||
programSchedule.TreatCollectionsAsShows = programSchedule.KeepMultiPartEpisodesTogether &&
|
||||
request.TreatCollectionsAsShows;
|
||||
|
||||
|
||||
@@ -9,7 +9,6 @@ namespace ErsatzTV.Application.ProgramSchedules
|
||||
new(
|
||||
programSchedule.Id,
|
||||
programSchedule.Name,
|
||||
programSchedule.MediaCollectionPlaybackOrder,
|
||||
programSchedule.KeepMultiPartEpisodesTogether,
|
||||
programSchedule.TreatCollectionsAsShows);
|
||||
|
||||
@@ -26,6 +25,9 @@ namespace ErsatzTV.Application.ProgramSchedules
|
||||
duration.Collection != null
|
||||
? MediaCollections.Mapper.ProjectToViewModel(duration.Collection)
|
||||
: null,
|
||||
duration.MultiCollection != null
|
||||
? MediaCollections.Mapper.ProjectToViewModel(duration.MultiCollection)
|
||||
: null,
|
||||
duration.MediaItem switch
|
||||
{
|
||||
Show show => MediaItems.Mapper.ProjectToViewModel(show),
|
||||
@@ -33,6 +35,7 @@ namespace ErsatzTV.Application.ProgramSchedules
|
||||
Artist artist => MediaItems.Mapper.ProjectToViewModel(artist),
|
||||
_ => null
|
||||
},
|
||||
duration.PlaybackOrder,
|
||||
duration.PlayoutDuration,
|
||||
duration.OfflineTail,
|
||||
duration.CustomTitle),
|
||||
@@ -46,6 +49,9 @@ namespace ErsatzTV.Application.ProgramSchedules
|
||||
flood.Collection != null
|
||||
? MediaCollections.Mapper.ProjectToViewModel(flood.Collection)
|
||||
: null,
|
||||
flood.MultiCollection != null
|
||||
? MediaCollections.Mapper.ProjectToViewModel(flood.MultiCollection)
|
||||
: null,
|
||||
flood.MediaItem switch
|
||||
{
|
||||
Show show => MediaItems.Mapper.ProjectToViewModel(show),
|
||||
@@ -53,6 +59,7 @@ namespace ErsatzTV.Application.ProgramSchedules
|
||||
Artist artist => MediaItems.Mapper.ProjectToViewModel(artist),
|
||||
_ => null
|
||||
},
|
||||
flood.PlaybackOrder,
|
||||
flood.CustomTitle),
|
||||
ProgramScheduleItemMultiple multiple =>
|
||||
new ProgramScheduleItemMultipleViewModel(
|
||||
@@ -64,6 +71,9 @@ namespace ErsatzTV.Application.ProgramSchedules
|
||||
multiple.Collection != null
|
||||
? MediaCollections.Mapper.ProjectToViewModel(multiple.Collection)
|
||||
: null,
|
||||
multiple.MultiCollection != null
|
||||
? MediaCollections.Mapper.ProjectToViewModel(multiple.MultiCollection)
|
||||
: null,
|
||||
multiple.MediaItem switch
|
||||
{
|
||||
Show show => MediaItems.Mapper.ProjectToViewModel(show),
|
||||
@@ -71,6 +81,7 @@ namespace ErsatzTV.Application.ProgramSchedules
|
||||
Artist artist => MediaItems.Mapper.ProjectToViewModel(artist),
|
||||
_ => null
|
||||
},
|
||||
multiple.PlaybackOrder,
|
||||
multiple.Count,
|
||||
multiple.CustomTitle),
|
||||
ProgramScheduleItemOne one =>
|
||||
@@ -83,6 +94,9 @@ namespace ErsatzTV.Application.ProgramSchedules
|
||||
one.Collection != null
|
||||
? MediaCollections.Mapper.ProjectToViewModel(one.Collection)
|
||||
: null,
|
||||
one.MultiCollection != null
|
||||
? MediaCollections.Mapper.ProjectToViewModel(one.MultiCollection)
|
||||
: null,
|
||||
one.MediaItem switch
|
||||
{
|
||||
Show show => MediaItems.Mapper.ProjectToViewModel(show),
|
||||
@@ -90,6 +104,7 @@ namespace ErsatzTV.Application.ProgramSchedules
|
||||
Artist artist => MediaItems.Mapper.ProjectToViewModel(artist),
|
||||
_ => null
|
||||
},
|
||||
one.PlaybackOrder,
|
||||
one.CustomTitle),
|
||||
_ => throw new NotSupportedException(
|
||||
$"Unsupported program schedule item type {programScheduleItem.GetType().Name}")
|
||||
|
||||
@@ -14,7 +14,9 @@ namespace ErsatzTV.Application.ProgramSchedules
|
||||
TimeSpan? startTime,
|
||||
ProgramScheduleItemCollectionType collectionType,
|
||||
MediaCollectionViewModel collection,
|
||||
MultiCollectionViewModel multiCollection,
|
||||
NamedMediaItemViewModel mediaItem,
|
||||
PlaybackOrder playbackOrder,
|
||||
TimeSpan playoutDuration,
|
||||
bool offlineTail,
|
||||
string customTitle) : base(
|
||||
@@ -25,7 +27,9 @@ namespace ErsatzTV.Application.ProgramSchedules
|
||||
PlayoutMode.Duration,
|
||||
collectionType,
|
||||
collection,
|
||||
multiCollection,
|
||||
mediaItem,
|
||||
playbackOrder,
|
||||
customTitle)
|
||||
{
|
||||
PlayoutDuration = playoutDuration;
|
||||
|
||||
@@ -14,7 +14,9 @@ namespace ErsatzTV.Application.ProgramSchedules
|
||||
TimeSpan? startTime,
|
||||
ProgramScheduleItemCollectionType collectionType,
|
||||
MediaCollectionViewModel collection,
|
||||
MultiCollectionViewModel multiCollection,
|
||||
NamedMediaItemViewModel mediaItem,
|
||||
PlaybackOrder playbackOrder,
|
||||
string customTitle) : base(
|
||||
id,
|
||||
index,
|
||||
@@ -23,7 +25,9 @@ namespace ErsatzTV.Application.ProgramSchedules
|
||||
PlayoutMode.Flood,
|
||||
collectionType,
|
||||
collection,
|
||||
multiCollection,
|
||||
mediaItem,
|
||||
playbackOrder,
|
||||
customTitle)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -14,7 +14,9 @@ namespace ErsatzTV.Application.ProgramSchedules
|
||||
TimeSpan? startTime,
|
||||
ProgramScheduleItemCollectionType collectionType,
|
||||
MediaCollectionViewModel collection,
|
||||
MultiCollectionViewModel multiCollection,
|
||||
NamedMediaItemViewModel mediaItem,
|
||||
PlaybackOrder playbackOrder,
|
||||
int count,
|
||||
string customTitle) : base(
|
||||
id,
|
||||
@@ -24,7 +26,9 @@ namespace ErsatzTV.Application.ProgramSchedules
|
||||
PlayoutMode.Multiple,
|
||||
collectionType,
|
||||
collection,
|
||||
multiCollection,
|
||||
mediaItem,
|
||||
playbackOrder,
|
||||
customTitle) =>
|
||||
Count = count;
|
||||
|
||||
|
||||
@@ -14,7 +14,9 @@ namespace ErsatzTV.Application.ProgramSchedules
|
||||
TimeSpan? startTime,
|
||||
ProgramScheduleItemCollectionType collectionType,
|
||||
MediaCollectionViewModel collection,
|
||||
MultiCollectionViewModel multiCollection,
|
||||
NamedMediaItemViewModel mediaItem,
|
||||
PlaybackOrder playbackOrder,
|
||||
string customTitle) : base(
|
||||
id,
|
||||
index,
|
||||
@@ -23,7 +25,9 @@ namespace ErsatzTV.Application.ProgramSchedules
|
||||
PlayoutMode.One,
|
||||
collectionType,
|
||||
collection,
|
||||
multiCollection,
|
||||
mediaItem,
|
||||
playbackOrder,
|
||||
customTitle)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -13,7 +13,9 @@ namespace ErsatzTV.Application.ProgramSchedules
|
||||
PlayoutMode PlayoutMode,
|
||||
ProgramScheduleItemCollectionType CollectionType,
|
||||
MediaCollectionViewModel Collection,
|
||||
MultiCollectionViewModel MultiCollection,
|
||||
NamedMediaItemViewModel MediaItem,
|
||||
PlaybackOrder PlaybackOrder,
|
||||
string CustomTitle)
|
||||
{
|
||||
public string Name => CollectionType switch
|
||||
@@ -25,6 +27,8 @@ namespace ErsatzTV.Application.ProgramSchedules
|
||||
MediaItem?.Name, // $"{TelevisionSeason?.Title} ({TelevisionSeason?.Plot})",
|
||||
ProgramScheduleItemCollectionType.Artist =>
|
||||
MediaItem?.Name,
|
||||
ProgramScheduleItemCollectionType.MultiCollection =>
|
||||
MultiCollection?.Name,
|
||||
_ => string.Empty
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
|
||||
namespace ErsatzTV.Application.ProgramSchedules
|
||||
namespace ErsatzTV.Application.ProgramSchedules
|
||||
{
|
||||
public record ProgramScheduleViewModel(
|
||||
int Id,
|
||||
string Name,
|
||||
PlaybackOrder MediaCollectionPlaybackOrder,
|
||||
bool KeepMultiPartEpisodesTogether,
|
||||
bool TreatCollectionsAsShows);
|
||||
}
|
||||
|
||||
@@ -24,7 +24,6 @@ namespace ErsatzTV.Application.ProgramSchedules.Queries
|
||||
ps => new ProgramScheduleViewModel(
|
||||
ps.Id,
|
||||
ps.Name,
|
||||
ps.MediaCollectionPlaybackOrder,
|
||||
ps.KeepMultiPartEpisodesTogether,
|
||||
ps.TreatCollectionsAsShows))
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
@@ -27,6 +27,7 @@ namespace ErsatzTV.Application.ProgramSchedules.Queries
|
||||
return await dbContext.ProgramScheduleItems
|
||||
.Filter(psi => psi.ProgramScheduleId == request.Id)
|
||||
.Include(i => i.Collection)
|
||||
.Include(i => i.MultiCollection)
|
||||
.Include(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as Movie).MovieMetadata)
|
||||
.ThenInclude(mm => mm.Artwork)
|
||||
|
||||
@@ -80,7 +80,7 @@ namespace ErsatzTV.Application.Television
|
||||
|
||||
if (maybeJellyfin.IsSome && artwork.StartsWith("jellyfin://"))
|
||||
{
|
||||
Url url = JellyfinUrl.ForArtwork(maybeJellyfin, artwork);
|
||||
Url url = JellyfinUrl.RelativeProxyForArtwork(artwork);
|
||||
if (artworkKind == ArtworkKind.Poster)
|
||||
{
|
||||
url.SetQueryParam("fillHeight", 440);
|
||||
@@ -90,7 +90,7 @@ namespace ErsatzTV.Application.Television
|
||||
}
|
||||
else if (maybeEmby.IsSome && artwork.StartsWith("emby://"))
|
||||
{
|
||||
Url url = EmbyUrl.ForArtwork(maybeEmby, artwork);
|
||||
Url url = EmbyUrl.RelativeProxyForArtwork(artwork);
|
||||
if (artworkKind == ArtworkKind.Poster)
|
||||
{
|
||||
url.SetQueryParam("maxHeight", 440);
|
||||
|
||||
@@ -10,11 +10,11 @@
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="FluentAssertions" Version="5.10.3" />
|
||||
<PackageReference Include="FluentAssertions" Version="6.0.0" />
|
||||
<PackageReference Include="LanguageExt.Core" Version="3.4.15" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="5.0.1" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.10.0" />
|
||||
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="16.9.60">
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="5.0.2" />
|
||||
<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>
|
||||
</PackageReference>
|
||||
|
||||
@@ -4,6 +4,7 @@ using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Scheduling;
|
||||
using LanguageExt;
|
||||
|
||||
namespace ErsatzTV.Core.Tests.Fakes
|
||||
@@ -18,7 +19,16 @@ namespace ErsatzTV.Core.Tests.Fakes
|
||||
throw new NotSupportedException();
|
||||
|
||||
public Task<List<MediaItem>> GetItems(int id) => _data[id].ToList().AsTask();
|
||||
public Task<List<MediaItem>> GetMultiCollectionItems(int id) => throw new NotSupportedException();
|
||||
|
||||
public Task<List<CollectionWithItems>> GetMultiCollectionCollections(int id) =>
|
||||
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<bool> IsCustomPlaybackOrder(int collectionId) => false.AsTask();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -329,14 +329,16 @@ namespace ErsatzTV.Core.Tests.Scheduling
|
||||
Index = 1,
|
||||
Collection = floodCollection,
|
||||
CollectionId = floodCollection.Id,
|
||||
StartTime = null
|
||||
StartTime = null,
|
||||
PlaybackOrder = PlaybackOrder.Chronological
|
||||
},
|
||||
new ProgramScheduleItemOne
|
||||
{
|
||||
Index = 2,
|
||||
Collection = fixedCollection,
|
||||
CollectionId = fixedCollection.Id,
|
||||
StartTime = TimeSpan.FromHours(3)
|
||||
StartTime = TimeSpan.FromHours(3),
|
||||
PlaybackOrder = PlaybackOrder.Chronological
|
||||
}
|
||||
};
|
||||
|
||||
@@ -344,15 +346,20 @@ namespace ErsatzTV.Core.Tests.Scheduling
|
||||
{
|
||||
ProgramSchedule = new ProgramSchedule
|
||||
{
|
||||
Items = items,
|
||||
MediaCollectionPlaybackOrder = PlaybackOrder.Chronological
|
||||
Items = items
|
||||
},
|
||||
Channel = new Channel(Guid.Empty) { Id = 1, Name = "Test Channel" }
|
||||
};
|
||||
|
||||
var configRepo = new Mock<IConfigElementRepository>();
|
||||
var televisionRepo = new FakeTelevisionRepository();
|
||||
var artistRepo = new Mock<IArtistRepository>();
|
||||
var builder = new PlayoutBuilder(fakeRepository, televisionRepo, artistRepo.Object, _logger);
|
||||
var builder = new PlayoutBuilder(
|
||||
configRepo.Object,
|
||||
fakeRepository,
|
||||
televisionRepo,
|
||||
artistRepo.Object,
|
||||
_logger);
|
||||
|
||||
DateTimeOffset start = HoursAfterMidnight(0);
|
||||
DateTimeOffset finish = start + TimeSpan.FromHours(6);
|
||||
@@ -409,7 +416,8 @@ namespace ErsatzTV.Core.Tests.Scheduling
|
||||
Index = 1,
|
||||
Collection = floodCollection,
|
||||
CollectionId = floodCollection.Id,
|
||||
StartTime = null
|
||||
StartTime = null,
|
||||
PlaybackOrder = PlaybackOrder.Chronological
|
||||
},
|
||||
new ProgramScheduleItemMultiple
|
||||
{
|
||||
@@ -417,7 +425,8 @@ namespace ErsatzTV.Core.Tests.Scheduling
|
||||
Collection = fixedCollection,
|
||||
CollectionId = fixedCollection.Id,
|
||||
StartTime = TimeSpan.FromHours(3),
|
||||
Count = 2
|
||||
Count = 2,
|
||||
PlaybackOrder = PlaybackOrder.Chronological
|
||||
}
|
||||
};
|
||||
|
||||
@@ -425,15 +434,20 @@ namespace ErsatzTV.Core.Tests.Scheduling
|
||||
{
|
||||
ProgramSchedule = new ProgramSchedule
|
||||
{
|
||||
Items = items,
|
||||
MediaCollectionPlaybackOrder = PlaybackOrder.Chronological
|
||||
Items = items
|
||||
},
|
||||
Channel = new Channel(Guid.Empty) { Id = 1, Name = "Test Channel" }
|
||||
};
|
||||
|
||||
var configRepo = new Mock<IConfigElementRepository>();
|
||||
var televisionRepo = new FakeTelevisionRepository();
|
||||
var artistRepo = new Mock<IArtistRepository>();
|
||||
var builder = new PlayoutBuilder(fakeRepository, televisionRepo, artistRepo.Object, _logger);
|
||||
var builder = new PlayoutBuilder(
|
||||
configRepo.Object,
|
||||
fakeRepository,
|
||||
televisionRepo,
|
||||
artistRepo.Object,
|
||||
_logger);
|
||||
|
||||
DateTimeOffset start = HoursAfterMidnight(0);
|
||||
DateTimeOffset finish = start + TimeSpan.FromHours(7);
|
||||
@@ -495,14 +509,16 @@ namespace ErsatzTV.Core.Tests.Scheduling
|
||||
Index = 1,
|
||||
Collection = floodCollection,
|
||||
CollectionId = floodCollection.Id,
|
||||
StartTime = TimeSpan.FromHours(7)
|
||||
StartTime = TimeSpan.FromHours(7),
|
||||
PlaybackOrder = PlaybackOrder.Chronological
|
||||
},
|
||||
new ProgramScheduleItemOne
|
||||
{
|
||||
Index = 2,
|
||||
Collection = fixedCollection,
|
||||
CollectionId = fixedCollection.Id,
|
||||
StartTime = TimeSpan.FromHours(12)
|
||||
StartTime = TimeSpan.FromHours(12),
|
||||
PlaybackOrder = PlaybackOrder.Chronological
|
||||
}
|
||||
};
|
||||
|
||||
@@ -510,15 +526,20 @@ namespace ErsatzTV.Core.Tests.Scheduling
|
||||
{
|
||||
ProgramSchedule = new ProgramSchedule
|
||||
{
|
||||
Items = items,
|
||||
MediaCollectionPlaybackOrder = PlaybackOrder.Chronological
|
||||
Items = items
|
||||
},
|
||||
Channel = new Channel(Guid.Empty) { Id = 1, Name = "Test Channel" }
|
||||
};
|
||||
|
||||
var configRepo = new Mock<IConfigElementRepository>();
|
||||
var televisionRepo = new FakeTelevisionRepository();
|
||||
var artistRepo = new Mock<IArtistRepository>();
|
||||
var builder = new PlayoutBuilder(fakeRepository, televisionRepo, artistRepo.Object, _logger);
|
||||
var builder = new PlayoutBuilder(
|
||||
configRepo.Object,
|
||||
fakeRepository,
|
||||
televisionRepo,
|
||||
artistRepo.Object,
|
||||
_logger);
|
||||
|
||||
DateTimeOffset start = HoursAfterMidnight(0);
|
||||
DateTimeOffset finish = start + TimeSpan.FromHours(24);
|
||||
@@ -579,14 +600,16 @@ namespace ErsatzTV.Core.Tests.Scheduling
|
||||
Index = 1,
|
||||
Collection = floodCollection,
|
||||
CollectionId = floodCollection.Id,
|
||||
StartTime = TimeSpan.FromHours(7)
|
||||
StartTime = TimeSpan.FromHours(7),
|
||||
PlaybackOrder = PlaybackOrder.Chronological
|
||||
},
|
||||
new ProgramScheduleItemOne
|
||||
{
|
||||
Index = 2,
|
||||
Collection = fixedCollection,
|
||||
CollectionId = fixedCollection.Id,
|
||||
StartTime = TimeSpan.FromHours(12)
|
||||
StartTime = TimeSpan.FromHours(12),
|
||||
PlaybackOrder = PlaybackOrder.Chronological
|
||||
}
|
||||
};
|
||||
|
||||
@@ -594,8 +617,7 @@ namespace ErsatzTV.Core.Tests.Scheduling
|
||||
{
|
||||
ProgramSchedule = new ProgramSchedule
|
||||
{
|
||||
Items = items,
|
||||
MediaCollectionPlaybackOrder = PlaybackOrder.Chronological
|
||||
Items = items
|
||||
},
|
||||
Channel = new Channel(Guid.Empty) { Id = 1, Name = "Test Channel" },
|
||||
Anchor = new PlayoutAnchor
|
||||
@@ -607,9 +629,15 @@ namespace ErsatzTV.Core.Tests.Scheduling
|
||||
}
|
||||
};
|
||||
|
||||
var configRepo = new Mock<IConfigElementRepository>();
|
||||
var televisionRepo = new FakeTelevisionRepository();
|
||||
var artistRepo = new Mock<IArtistRepository>();
|
||||
var builder = new PlayoutBuilder(fakeRepository, televisionRepo, artistRepo.Object, _logger);
|
||||
var builder = new PlayoutBuilder(
|
||||
configRepo.Object,
|
||||
fakeRepository,
|
||||
televisionRepo,
|
||||
artistRepo.Object,
|
||||
_logger);
|
||||
|
||||
DateTimeOffset start = HoursAfterMidnight(0);
|
||||
DateTimeOffset finish = start + TimeSpan.FromHours(32);
|
||||
@@ -671,7 +699,8 @@ namespace ErsatzTV.Core.Tests.Scheduling
|
||||
Index = 1,
|
||||
Collection = floodCollection,
|
||||
CollectionId = floodCollection.Id,
|
||||
StartTime = null
|
||||
StartTime = null,
|
||||
PlaybackOrder = PlaybackOrder.Chronological
|
||||
},
|
||||
new ProgramScheduleItemDuration
|
||||
{
|
||||
@@ -680,7 +709,8 @@ namespace ErsatzTV.Core.Tests.Scheduling
|
||||
CollectionId = fixedCollection.Id,
|
||||
StartTime = TimeSpan.FromHours(2),
|
||||
PlayoutDuration = TimeSpan.FromHours(2),
|
||||
OfflineTail = false // immediately continue
|
||||
OfflineTail = false, // immediately continue
|
||||
PlaybackOrder = PlaybackOrder.Chronological
|
||||
}
|
||||
};
|
||||
|
||||
@@ -688,15 +718,20 @@ namespace ErsatzTV.Core.Tests.Scheduling
|
||||
{
|
||||
ProgramSchedule = new ProgramSchedule
|
||||
{
|
||||
Items = items,
|
||||
MediaCollectionPlaybackOrder = PlaybackOrder.Chronological
|
||||
Items = items
|
||||
},
|
||||
Channel = new Channel(Guid.Empty) { Id = 1, Name = "Test Channel" }
|
||||
};
|
||||
|
||||
var configRepo = new Mock<IConfigElementRepository>();
|
||||
var televisionRepo = new FakeTelevisionRepository();
|
||||
var artistRepo = new Mock<IArtistRepository>();
|
||||
var builder = new PlayoutBuilder(fakeRepository, televisionRepo, artistRepo.Object, _logger);
|
||||
var builder = new PlayoutBuilder(
|
||||
configRepo.Object,
|
||||
fakeRepository,
|
||||
televisionRepo,
|
||||
artistRepo.Object,
|
||||
_logger);
|
||||
|
||||
DateTimeOffset start = HoursAfterMidnight(0);
|
||||
DateTimeOffset finish = start + TimeSpan.FromHours(6);
|
||||
@@ -762,7 +797,8 @@ namespace ErsatzTV.Core.Tests.Scheduling
|
||||
Collection = multipleCollection,
|
||||
CollectionId = multipleCollection.Id,
|
||||
StartTime = null,
|
||||
Count = 2
|
||||
Count = 2,
|
||||
PlaybackOrder = PlaybackOrder.Chronological
|
||||
},
|
||||
new ProgramScheduleItemDuration
|
||||
{
|
||||
@@ -771,7 +807,8 @@ namespace ErsatzTV.Core.Tests.Scheduling
|
||||
CollectionId = dynamicCollection.Id,
|
||||
StartTime = null,
|
||||
PlayoutDuration = TimeSpan.FromHours(2),
|
||||
OfflineTail = false // immediately continue
|
||||
OfflineTail = false, // immediately continue
|
||||
PlaybackOrder = PlaybackOrder.Chronological
|
||||
}
|
||||
};
|
||||
|
||||
@@ -779,15 +816,20 @@ namespace ErsatzTV.Core.Tests.Scheduling
|
||||
{
|
||||
ProgramSchedule = new ProgramSchedule
|
||||
{
|
||||
Items = items,
|
||||
MediaCollectionPlaybackOrder = PlaybackOrder.Chronological
|
||||
Items = items
|
||||
},
|
||||
Channel = new Channel(Guid.Empty) { Id = 1, Name = "Test Channel" }
|
||||
};
|
||||
|
||||
var configRepo = new Mock<IConfigElementRepository>();
|
||||
var televisionRepo = new FakeTelevisionRepository();
|
||||
var artistRepo = new Mock<IArtistRepository>();
|
||||
var builder = new PlayoutBuilder(fakeRepository, televisionRepo, artistRepo.Object, _logger);
|
||||
var builder = new PlayoutBuilder(
|
||||
configRepo.Object,
|
||||
fakeRepository,
|
||||
televisionRepo,
|
||||
artistRepo.Object,
|
||||
_logger);
|
||||
|
||||
DateTimeOffset start = HoursAfterMidnight(0);
|
||||
DateTimeOffset finish = start + TimeSpan.FromHours(6);
|
||||
@@ -850,7 +892,8 @@ namespace ErsatzTV.Core.Tests.Scheduling
|
||||
Collection = collectionOne,
|
||||
CollectionId = collectionOne.Id,
|
||||
StartTime = null,
|
||||
Count = 3
|
||||
Count = 3,
|
||||
PlaybackOrder = PlaybackOrder.Chronological
|
||||
},
|
||||
new ProgramScheduleItemMultiple
|
||||
{
|
||||
@@ -859,7 +902,8 @@ namespace ErsatzTV.Core.Tests.Scheduling
|
||||
Collection = collectionTwo,
|
||||
CollectionId = collectionTwo.Id,
|
||||
StartTime = null,
|
||||
Count = 3
|
||||
Count = 3,
|
||||
PlaybackOrder = PlaybackOrder.Chronological
|
||||
}
|
||||
};
|
||||
|
||||
@@ -867,8 +911,7 @@ namespace ErsatzTV.Core.Tests.Scheduling
|
||||
{
|
||||
ProgramSchedule = new ProgramSchedule
|
||||
{
|
||||
Items = items,
|
||||
MediaCollectionPlaybackOrder = PlaybackOrder.Chronological
|
||||
Items = items
|
||||
},
|
||||
Channel = new Channel(Guid.Empty) { Id = 1, Name = "Test Channel" },
|
||||
Anchor = new PlayoutAnchor
|
||||
@@ -880,9 +923,15 @@ namespace ErsatzTV.Core.Tests.Scheduling
|
||||
}
|
||||
};
|
||||
|
||||
var configRepo = new Mock<IConfigElementRepository>();
|
||||
var televisionRepo = new FakeTelevisionRepository();
|
||||
var artistRepo = new Mock<IArtistRepository>();
|
||||
var builder = new PlayoutBuilder(fakeRepository, televisionRepo, artistRepo.Object, _logger);
|
||||
var builder = new PlayoutBuilder(
|
||||
configRepo.Object,
|
||||
fakeRepository,
|
||||
televisionRepo,
|
||||
artistRepo.Object,
|
||||
_logger);
|
||||
|
||||
DateTimeOffset start = HoursAfterMidnight(0);
|
||||
DateTimeOffset finish = start + TimeSpan.FromHours(5);
|
||||
@@ -945,7 +994,8 @@ namespace ErsatzTV.Core.Tests.Scheduling
|
||||
Collection = collectionOne,
|
||||
CollectionId = collectionOne.Id,
|
||||
StartTime = null,
|
||||
Count = 0
|
||||
Count = 0,
|
||||
PlaybackOrder = PlaybackOrder.Chronological
|
||||
},
|
||||
new ProgramScheduleItemMultiple
|
||||
{
|
||||
@@ -954,7 +1004,8 @@ namespace ErsatzTV.Core.Tests.Scheduling
|
||||
Collection = collectionTwo,
|
||||
CollectionId = collectionTwo.Id,
|
||||
StartTime = null,
|
||||
Count = 0
|
||||
Count = 0,
|
||||
PlaybackOrder = PlaybackOrder.Chronological
|
||||
}
|
||||
};
|
||||
|
||||
@@ -962,15 +1013,20 @@ namespace ErsatzTV.Core.Tests.Scheduling
|
||||
{
|
||||
ProgramSchedule = new ProgramSchedule
|
||||
{
|
||||
Items = items,
|
||||
MediaCollectionPlaybackOrder = PlaybackOrder.Chronological
|
||||
Items = items
|
||||
},
|
||||
Channel = new Channel(Guid.Empty) { Id = 1, Name = "Test Channel" },
|
||||
};
|
||||
|
||||
var configRepo = new Mock<IConfigElementRepository>();
|
||||
var televisionRepo = new FakeTelevisionRepository();
|
||||
var artistRepo = new Mock<IArtistRepository>();
|
||||
var builder = new PlayoutBuilder(fakeRepository, televisionRepo, artistRepo.Object, _logger);
|
||||
var builder = new PlayoutBuilder(
|
||||
configRepo.Object,
|
||||
fakeRepository,
|
||||
televisionRepo,
|
||||
artistRepo.Object,
|
||||
_logger);
|
||||
|
||||
DateTimeOffset start = HoursAfterMidnight(0);
|
||||
DateTimeOffset finish = start + TimeSpan.FromHours(5);
|
||||
@@ -1033,7 +1089,8 @@ namespace ErsatzTV.Core.Tests.Scheduling
|
||||
CollectionId = collectionOne.Id,
|
||||
StartTime = null,
|
||||
PlayoutDuration = TimeSpan.FromHours(3),
|
||||
OfflineTail = false
|
||||
OfflineTail = false,
|
||||
PlaybackOrder = PlaybackOrder.Chronological
|
||||
},
|
||||
new ProgramScheduleItemDuration
|
||||
{
|
||||
@@ -1043,7 +1100,8 @@ namespace ErsatzTV.Core.Tests.Scheduling
|
||||
CollectionId = collectionTwo.Id,
|
||||
StartTime = null,
|
||||
PlayoutDuration = TimeSpan.FromHours(3),
|
||||
OfflineTail = false
|
||||
OfflineTail = false,
|
||||
PlaybackOrder = PlaybackOrder.Chronological
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1051,8 +1109,7 @@ namespace ErsatzTV.Core.Tests.Scheduling
|
||||
{
|
||||
ProgramSchedule = new ProgramSchedule
|
||||
{
|
||||
Items = items,
|
||||
MediaCollectionPlaybackOrder = PlaybackOrder.Chronological
|
||||
Items = items
|
||||
},
|
||||
Channel = new Channel(Guid.Empty) { Id = 1, Name = "Test Channel" },
|
||||
Anchor = new PlayoutAnchor
|
||||
@@ -1064,9 +1121,15 @@ namespace ErsatzTV.Core.Tests.Scheduling
|
||||
}
|
||||
};
|
||||
|
||||
var configRepo = new Mock<IConfigElementRepository>();
|
||||
var televisionRepo = new FakeTelevisionRepository();
|
||||
var artistRepo = new Mock<IArtistRepository>();
|
||||
var builder = new PlayoutBuilder(fakeRepository, televisionRepo, artistRepo.Object, _logger);
|
||||
var builder = new PlayoutBuilder(
|
||||
configRepo.Object,
|
||||
fakeRepository,
|
||||
televisionRepo,
|
||||
artistRepo.Object,
|
||||
_logger);
|
||||
|
||||
DateTimeOffset start = HoursAfterMidnight(0);
|
||||
DateTimeOffset finish = start + TimeSpan.FromHours(5);
|
||||
@@ -1095,13 +1158,14 @@ namespace ErsatzTV.Core.Tests.Scheduling
|
||||
return now - now.TimeOfDay + TimeSpan.FromHours(hours);
|
||||
}
|
||||
|
||||
private static ProgramScheduleItem Flood(Collection mediaCollection) =>
|
||||
private static ProgramScheduleItem Flood(Collection mediaCollection, PlaybackOrder playbackOrder) =>
|
||||
new ProgramScheduleItemFlood
|
||||
{
|
||||
Index = 1,
|
||||
Collection = mediaCollection,
|
||||
CollectionId = mediaCollection.Id,
|
||||
StartTime = null
|
||||
StartTime = null,
|
||||
PlaybackOrder = playbackOrder
|
||||
};
|
||||
|
||||
private static Movie TestMovie(int id, TimeSpan duration, DateTime aired) =>
|
||||
@@ -1123,17 +1187,23 @@ namespace ErsatzTV.Core.Tests.Scheduling
|
||||
MediaItems = mediaItems
|
||||
};
|
||||
|
||||
var configRepo = new Mock<IConfigElementRepository>();
|
||||
var collectionRepo = new FakeMediaCollectionRepository(Map((mediaCollection.Id, mediaItems)));
|
||||
var televisionRepo = new FakeTelevisionRepository();
|
||||
var artistRepo = new Mock<IArtistRepository>();
|
||||
var builder = new PlayoutBuilder(collectionRepo, televisionRepo, artistRepo.Object, _logger);
|
||||
var builder = new PlayoutBuilder(
|
||||
configRepo.Object,
|
||||
collectionRepo,
|
||||
televisionRepo,
|
||||
artistRepo.Object,
|
||||
_logger);
|
||||
|
||||
var items = new List<ProgramScheduleItem> { Flood(mediaCollection) };
|
||||
var items = new List<ProgramScheduleItem> { Flood(mediaCollection, playbackOrder) };
|
||||
|
||||
var playout = new Playout
|
||||
{
|
||||
Id = 1,
|
||||
ProgramSchedule = new ProgramSchedule { Items = items, MediaCollectionPlaybackOrder = playbackOrder },
|
||||
ProgramSchedule = new ProgramSchedule { Items = items },
|
||||
Channel = new Channel(Guid.Empty) { Id = 1, Name = "Test Channel" }
|
||||
};
|
||||
|
||||
|
||||
@@ -23,7 +23,8 @@ namespace ErsatzTV.Core.Tests.Scheduling
|
||||
// normally returns 10 5 7 4 3 6 2 8 9 1 1 (note duplicate 1 at end)
|
||||
var state = new CollectionEnumeratorState { Seed = 8 };
|
||||
|
||||
var shuffledContent = new ShuffledMediaCollectionEnumerator(contents, state, false, false);
|
||||
var groupedMediaItems = contents.Map(mi => new GroupedMediaItem(mi, null)).ToList();
|
||||
var shuffledContent = new ShuffledMediaCollectionEnumerator(groupedMediaItems, state);
|
||||
|
||||
var list = new List<int>();
|
||||
for (var i = 1; i <= 1000; i++)
|
||||
@@ -50,7 +51,8 @@ namespace ErsatzTV.Core.Tests.Scheduling
|
||||
|
||||
var state = new CollectionEnumeratorState();
|
||||
|
||||
var shuffledContent = new ShuffledMediaCollectionEnumerator(contents, state, false, false);
|
||||
var groupedMediaItems = contents.Map(mi => new GroupedMediaItem(mi, null)).ToList();
|
||||
var shuffledContent = new ShuffledMediaCollectionEnumerator(groupedMediaItems, state);
|
||||
|
||||
var list = new List<int>();
|
||||
for (var i = 1; i <= 10; i++)
|
||||
@@ -70,7 +72,8 @@ namespace ErsatzTV.Core.Tests.Scheduling
|
||||
|
||||
var state = new CollectionEnumeratorState();
|
||||
|
||||
var shuffledContent = new ShuffledMediaCollectionEnumerator(contents, state, false, false);
|
||||
var groupedMediaItems = contents.Map(mi => new GroupedMediaItem(mi, null)).ToList();
|
||||
var shuffledContent = new ShuffledMediaCollectionEnumerator(groupedMediaItems, state);
|
||||
|
||||
var list = new List<int>();
|
||||
for (var i = 1; i <= 10; i++)
|
||||
@@ -81,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]
|
||||
@@ -90,7 +93,8 @@ namespace ErsatzTV.Core.Tests.Scheduling
|
||||
List<MediaItem> contents = Episodes(10);
|
||||
var state = new CollectionEnumeratorState();
|
||||
|
||||
var shuffledContent = new ShuffledMediaCollectionEnumerator(contents, state, false, false);
|
||||
var groupedMediaItems = contents.Map(mi => new GroupedMediaItem(mi, null)).ToList();
|
||||
var shuffledContent = new ShuffledMediaCollectionEnumerator(groupedMediaItems, state);
|
||||
|
||||
for (var i = 0; i < 10; i++)
|
||||
{
|
||||
@@ -105,7 +109,8 @@ namespace ErsatzTV.Core.Tests.Scheduling
|
||||
List<MediaItem> contents = Episodes(10);
|
||||
var state = new CollectionEnumeratorState { Index = 5, Seed = MagicSeed };
|
||||
|
||||
var shuffledContent = new ShuffledMediaCollectionEnumerator(contents, state, false, false);
|
||||
var groupedMediaItems = contents.Map(mi => new GroupedMediaItem(mi, null)).ToList();
|
||||
var shuffledContent = new ShuffledMediaCollectionEnumerator(groupedMediaItems, state);
|
||||
|
||||
for (var i = 6; i <= 10; i++)
|
||||
{
|
||||
@@ -123,7 +128,8 @@ namespace ErsatzTV.Core.Tests.Scheduling
|
||||
List<MediaItem> contents = Episodes(10);
|
||||
var state = new CollectionEnumeratorState { Index = 10, Seed = MagicSeed };
|
||||
|
||||
var shuffledContent = new ShuffledMediaCollectionEnumerator(contents, state, false, false);
|
||||
var groupedMediaItems = contents.Map(mi => new GroupedMediaItem(mi, null)).ToList();
|
||||
var shuffledContent = new ShuffledMediaCollectionEnumerator(groupedMediaItems, state);
|
||||
|
||||
shuffledContent.State.Index.Should().Be(0);
|
||||
shuffledContent.State.Seed.Should().NotBe(MagicSeed);
|
||||
|
||||
@@ -9,5 +9,7 @@ namespace ErsatzTV.Core.Domain
|
||||
public bool UseCustomPlaybackOrder { get; set; }
|
||||
public List<MediaItem> MediaItems { get; set; }
|
||||
public List<CollectionItem> CollectionItems { get; set; }
|
||||
public List<MultiCollection> MultiCollections { get; set; }
|
||||
public List<MultiCollectionItem> MultiCollectionItems { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace ErsatzTV.Core.Domain
|
||||
{
|
||||
public class MultiCollection
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string Name { get; set; }
|
||||
public List<Collection> Collections { get; set; }
|
||||
public List<MultiCollectionItem> MultiCollectionItems { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace ErsatzTV.Core.Domain
|
||||
{
|
||||
public class MultiCollectionItem
|
||||
{
|
||||
public int MultiCollectionId { get; set; }
|
||||
public MultiCollection MultiCollection { get; set; }
|
||||
public int CollectionId { get; set; }
|
||||
public Collection Collection { get; set; }
|
||||
public bool ScheduleAsGroup { get; set; }
|
||||
public PlaybackOrder PlaybackOrder { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -17,11 +17,13 @@
|
||||
public static ConfigElementKey HDHRTunerCount => new("hdhr.tuner_count");
|
||||
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 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");
|
||||
public static ConfigElementKey PlayoutsDetailPageSize => new("pages.playouts.detail_page_size");
|
||||
public static ConfigElementKey LogsPageSize => new("pages.logs.page_size");
|
||||
public static ConfigElementKey LibraryRefreshInterval => new("scanner.library_refresh_interval");
|
||||
public static ConfigElementKey PlayoutDaysToBuild => new("playout.days_to_build");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
{
|
||||
Chronological = 1,
|
||||
Random = 2,
|
||||
Shuffle = 3
|
||||
Shuffle = 3,
|
||||
ShuffleInOrder = 4
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
public ProgramScheduleItemCollectionType CollectionType { get; set; }
|
||||
public int? CollectionId { get; set; }
|
||||
public Collection Collection { get; set; }
|
||||
public int? MultiCollectionId { get; set; }
|
||||
public MultiCollection MultiCollection { get; set; }
|
||||
public int? MediaItemId { get; set; }
|
||||
public MediaItem MediaItem { get; set; }
|
||||
public CollectionEnumeratorState EnumeratorState { get; set; }
|
||||
|
||||
@@ -6,7 +6,6 @@ namespace ErsatzTV.Core.Domain
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string Name { get; set; }
|
||||
public PlaybackOrder MediaCollectionPlaybackOrder { get; set; }
|
||||
public bool KeepMultiPartEpisodesTogether { get; set; }
|
||||
public bool TreatCollectionsAsShows { get; set; }
|
||||
public List<ProgramScheduleItem> Items { get; set; }
|
||||
|
||||
@@ -16,5 +16,8 @@ namespace ErsatzTV.Core.Domain
|
||||
public Collection Collection { get; set; }
|
||||
public int? MediaItemId { get; set; }
|
||||
public MediaItem MediaItem { get; set; }
|
||||
public int? MultiCollectionId { get; set; }
|
||||
public MultiCollection MultiCollection { get; set; }
|
||||
public PlaybackOrder PlaybackOrder { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
Collection = 0,
|
||||
TelevisionShow = 1,
|
||||
TelevisionSeason = 2,
|
||||
Artist = 3
|
||||
Artist = 3,
|
||||
MultiCollection = 4
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,5 +63,21 @@ namespace ErsatzTV.Core.Emby
|
||||
.AppendPathSegment(pathSegment)
|
||||
.SetQueryParams(query);
|
||||
}
|
||||
|
||||
public static Url RelativeProxyForArtwork(string artwork)
|
||||
{
|
||||
string[] split = artwork.Replace("emby://", string.Empty).Split('?');
|
||||
if (split.Length != 2)
|
||||
{
|
||||
return artwork;
|
||||
}
|
||||
|
||||
string pathSegment = split[0];
|
||||
QueryParamCollection query = Url.ParseQueryParams(split[1]);
|
||||
|
||||
return Url.Parse("emby")
|
||||
.AppendPathSegment(pathSegment)
|
||||
.SetQueryParams(query);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,7 +22,13 @@
|
||||
</PackageReference>
|
||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.1" />
|
||||
<PackageReference Include="Serilog" Version="2.10.0" />
|
||||
<PackageReference Include="Serilog.Sinks.Console" Version="3.1.1" />
|
||||
<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>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Scheduling;
|
||||
using LanguageExt;
|
||||
|
||||
namespace ErsatzTV.Core.Interfaces.Repositories
|
||||
@@ -9,7 +10,10 @@ 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<CollectionWithItems>> GetMultiCollectionCollections(int id);
|
||||
Task<List<int>> PlayoutIdsUsingCollection(int collectionId);
|
||||
Task<List<int>> PlayoutIdsUsingMultiCollection(int multiCollectionId);
|
||||
Task<bool> IsCustomPlaybackOrder(int collectionId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,5 +63,22 @@ namespace ErsatzTV.Core.Jellyfin
|
||||
.AppendPathSegment(pathSegment)
|
||||
.SetQueryParams(query);
|
||||
}
|
||||
|
||||
public static Url RelativeProxyForArtwork(string artwork)
|
||||
{
|
||||
string[] split = artwork.Replace("jellyfin://", string.Empty).Split('?');
|
||||
if (split.Length != 2)
|
||||
{
|
||||
return artwork;
|
||||
}
|
||||
|
||||
string pathSegment = split[0];
|
||||
QueryParamCollection query = Url.ParseQueryParams(split[1]);
|
||||
|
||||
return Url.Parse("jellyfin")
|
||||
.AppendPathSegment(pathSegment)
|
||||
.SetQueryParams(query);
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
using System.Collections.Generic;
|
||||
using ErsatzTV.Core.Domain;
|
||||
|
||||
namespace ErsatzTV.Core.Scheduling
|
||||
{
|
||||
public record CollectionWithItems(
|
||||
int CollectionId,
|
||||
List<MediaItem> MediaItems,
|
||||
bool ScheduleAsGroup,
|
||||
PlaybackOrder PlaybackOrder,
|
||||
bool UseCustomOrder);
|
||||
}
|
||||
@@ -1,9 +1,40 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using static LanguageExt.Prelude;
|
||||
|
||||
namespace ErsatzTV.Core.Scheduling
|
||||
{
|
||||
[DebuggerDisplay("{First}")]
|
||||
public record GroupedMediaItem(MediaItem First, List<MediaItem> Additional);
|
||||
[DebuggerDisplay("{" + nameof(First) + "}")]
|
||||
public class GroupedMediaItem
|
||||
{
|
||||
public GroupedMediaItem()
|
||||
{
|
||||
}
|
||||
|
||||
public GroupedMediaItem(MediaItem first, List<MediaItem> additional)
|
||||
{
|
||||
First = first;
|
||||
Additional = additional ?? new List<MediaItem>();
|
||||
}
|
||||
|
||||
public MediaItem First { get; set; }
|
||||
public List<MediaItem> Additional { get; set; }
|
||||
|
||||
public static IList<MediaItem> FlattenGroups(GroupedMediaItem[] copy, int mediaItemCount)
|
||||
{
|
||||
var result = new MediaItem[mediaItemCount];
|
||||
var i = 0;
|
||||
foreach (GroupedMediaItem group in copy)
|
||||
{
|
||||
result[i++] = group.First;
|
||||
foreach (MediaItem additional in Optional(group.Additional).Flatten())
|
||||
{
|
||||
result[i++] = additional;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using static LanguageExt.Prelude;
|
||||
|
||||
namespace ErsatzTV.Core.Scheduling
|
||||
{
|
||||
public class MultiCollectionGroup : GroupedMediaItem
|
||||
{
|
||||
public MultiCollectionGroup(CollectionWithItems collectionWithItems)
|
||||
{
|
||||
if (collectionWithItems.UseCustomOrder)
|
||||
{
|
||||
if (collectionWithItems.MediaItems.Count > 0)
|
||||
{
|
||||
First = collectionWithItems.MediaItems.Head();
|
||||
Additional = collectionWithItems.MediaItems.Tail().ToList();
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new InvalidOperationException("Collection has no items");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
switch (collectionWithItems.PlaybackOrder)
|
||||
{
|
||||
case PlaybackOrder.Chronological:
|
||||
var sortedItems = collectionWithItems.MediaItems.OrderBy(identity, new ChronologicalMediaComparer())
|
||||
.ToList();
|
||||
First = sortedItems.Head();
|
||||
Additional = sortedItems.Tail().ToList();
|
||||
break;
|
||||
default:
|
||||
throw new NotSupportedException(
|
||||
$"Unsupported MultiCollection PlaybackOrder: {collectionWithItems.PlaybackOrder}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace ErsatzTV.Core.Scheduling
|
||||
{
|
||||
public class MultiCollectionGrouper
|
||||
{
|
||||
public static List<GroupedMediaItem> GroupMediaItems(IList<CollectionWithItems> collections)
|
||||
{
|
||||
var result = new List<GroupedMediaItem>();
|
||||
|
||||
foreach (CollectionWithItems collection in collections.Where(collection => collection.MediaItems.Any()))
|
||||
{
|
||||
if (collection.ScheduleAsGroup)
|
||||
{
|
||||
result.Add(new MultiCollectionGroup(collection));
|
||||
}
|
||||
else
|
||||
{
|
||||
result.AddRange(collection.MediaItems.Map(i => new GroupedMediaItem { First = i }));
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -77,7 +77,7 @@ namespace ErsatzTV.Core.Scheduling
|
||||
// add to current group
|
||||
List<MediaItem> additional = group.Additional ?? new List<MediaItem>();
|
||||
additional.Add(episode);
|
||||
group = group with { Additional = additional };
|
||||
group = new GroupedMediaItem(group.First, additional);
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -152,22 +152,6 @@ namespace ErsatzTV.Core.Scheduling
|
||||
return None;
|
||||
}
|
||||
|
||||
public static IList<MediaItem> FlattenGroups(GroupedMediaItem[] copy, int mediaItemCount)
|
||||
{
|
||||
var result = new MediaItem[mediaItemCount];
|
||||
var i = 0;
|
||||
foreach (GroupedMediaItem group in copy)
|
||||
{
|
||||
result[i++] = group.First;
|
||||
foreach (MediaItem additional in Optional(group.Additional).Flatten())
|
||||
{
|
||||
result[i++] = additional;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static bool TryParseRoman(string input, out int output)
|
||||
{
|
||||
switch (input?.ToLowerInvariant())
|
||||
|
||||
@@ -20,25 +20,29 @@ namespace ErsatzTV.Core.Scheduling
|
||||
private static readonly Random Random = new();
|
||||
private readonly IArtistRepository _artistRepository;
|
||||
private readonly ILogger<PlayoutBuilder> _logger;
|
||||
private readonly IConfigElementRepository _configElementRepository;
|
||||
private readonly IMediaCollectionRepository _mediaCollectionRepository;
|
||||
private readonly ITelevisionRepository _televisionRepository;
|
||||
|
||||
public PlayoutBuilder(
|
||||
IConfigElementRepository configElementRepository,
|
||||
IMediaCollectionRepository mediaCollectionRepository,
|
||||
ITelevisionRepository televisionRepository,
|
||||
IArtistRepository artistRepository,
|
||||
ILogger<PlayoutBuilder> logger)
|
||||
{
|
||||
_configElementRepository = configElementRepository;
|
||||
_mediaCollectionRepository = mediaCollectionRepository;
|
||||
_televisionRepository = televisionRepository;
|
||||
_artistRepository = artistRepository;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public Task<Playout> BuildPlayoutItems(Playout playout, bool rebuild = false)
|
||||
public async Task<Playout> BuildPlayoutItems(Playout playout, bool rebuild = false)
|
||||
{
|
||||
DateTimeOffset now = DateTimeOffset.Now;
|
||||
return BuildPlayoutItems(playout, now, now.AddDays(2), rebuild);
|
||||
Option<int> daysToBuild = await _configElementRepository.GetValue<int>(ConfigElementKey.PlayoutDaysToBuild);
|
||||
return await BuildPlayoutItems(playout, now, now.AddDays(await daysToBuild.IfNoneAsync(2)), rebuild);
|
||||
}
|
||||
|
||||
public async Task<Playout> BuildPlayoutItems(
|
||||
@@ -82,6 +86,11 @@ namespace ErsatzTV.Core.Scheduling
|
||||
List<MusicVideo> artistItems =
|
||||
await _artistRepository.GetArtistItems(collectionKey.MediaItemId ?? 0);
|
||||
return Tuple(collectionKey, artistItems.Cast<MediaItem>().ToList());
|
||||
case ProgramScheduleItemCollectionType.MultiCollection:
|
||||
List<MediaItem> multiCollectionItems =
|
||||
await _mediaCollectionRepository.GetMultiCollectionItems(
|
||||
collectionKey.MultiCollectionId ?? 0);
|
||||
return Tuple(collectionKey, multiCollectionItems);
|
||||
default:
|
||||
return Tuple(collectionKey, new List<MediaItem>());
|
||||
}
|
||||
@@ -178,8 +187,11 @@ namespace ErsatzTV.Core.Scheduling
|
||||
var collectionEnumerators = new Dictionary<CollectionKey, IMediaCollectionEnumerator>();
|
||||
foreach ((CollectionKey collectionKey, List<MediaItem> mediaItems) in collectionMediaItems)
|
||||
{
|
||||
PlaybackOrder playbackOrder = sortedScheduleItems
|
||||
.First(item => CollectionKeyForItem(item) == collectionKey)
|
||||
.PlaybackOrder;
|
||||
IMediaCollectionEnumerator enumerator =
|
||||
await GetMediaCollectionEnumerator(playout, collectionKey, mediaItems);
|
||||
await GetMediaCollectionEnumerator(playout, collectionKey, mediaItems, playbackOrder);
|
||||
collectionEnumerators.Add(collectionKey, enumerator);
|
||||
}
|
||||
|
||||
@@ -384,15 +396,6 @@ namespace ErsatzTV.Core.Scheduling
|
||||
|
||||
// once more to get playout anchor
|
||||
ProgramScheduleItem nextScheduleItem = sortedScheduleItems[index % sortedScheduleItems.Count];
|
||||
playout.Anchor = new PlayoutAnchor
|
||||
{
|
||||
NextScheduleItem = nextScheduleItem,
|
||||
NextScheduleItemId = nextScheduleItem.Id,
|
||||
NextStart = GetStartTimeAfter(nextScheduleItem, currentTime).UtcDateTime,
|
||||
MultipleRemaining = multipleRemaining.IsSome ? multipleRemaining.ValueUnsafe() : null,
|
||||
DurationFinish = durationFinish.IsSome ? durationFinish.ValueUnsafe().UtcDateTime : null,
|
||||
InFlood = inFlood
|
||||
};
|
||||
|
||||
// build program schedule anchors
|
||||
playout.ProgramScheduleAnchors = BuildProgramScheduleAnchors(playout, collectionEnumerators);
|
||||
@@ -400,6 +403,26 @@ namespace ErsatzTV.Core.Scheduling
|
||||
// remove any items outside the desired range
|
||||
playout.Items.RemoveAll(old => old.FinishOffset < playoutStart || old.StartOffset > playoutFinish);
|
||||
|
||||
DateTimeOffset minCurrentTime = currentTime;
|
||||
if (playout.Items.Any())
|
||||
{
|
||||
DateTimeOffset maxStartTime = playout.Items.Max(i => i.FinishOffset);
|
||||
if (maxStartTime < currentTime)
|
||||
{
|
||||
minCurrentTime = maxStartTime;
|
||||
}
|
||||
}
|
||||
|
||||
playout.Anchor = new PlayoutAnchor
|
||||
{
|
||||
NextScheduleItem = nextScheduleItem,
|
||||
NextScheduleItemId = nextScheduleItem.Id,
|
||||
NextStart = GetStartTimeAfter(nextScheduleItem, minCurrentTime).UtcDateTime,
|
||||
MultipleRemaining = multipleRemaining.IsSome ? multipleRemaining.ValueUnsafe() : null,
|
||||
DurationFinish = durationFinish.IsSome ? durationFinish.ValueUnsafe().UtcDateTime : null,
|
||||
InFlood = inFlood
|
||||
};
|
||||
|
||||
return playout;
|
||||
}
|
||||
|
||||
@@ -490,6 +513,7 @@ namespace ErsatzTV.Core.Scheduling
|
||||
ProgramScheduleId = playout.ProgramScheduleId,
|
||||
CollectionType = collectionKey.CollectionType,
|
||||
CollectionId = collectionKey.CollectionId,
|
||||
MultiCollectionId = collectionKey.MultiCollectionId,
|
||||
MediaItemId = collectionKey.MediaItemId,
|
||||
EnumeratorState = maybeEnumeratorState[collectionKey]
|
||||
});
|
||||
@@ -503,12 +527,14 @@ namespace ErsatzTV.Core.Scheduling
|
||||
private async Task<IMediaCollectionEnumerator> GetMediaCollectionEnumerator(
|
||||
Playout playout,
|
||||
CollectionKey collectionKey,
|
||||
List<MediaItem> mediaItems)
|
||||
List<MediaItem> mediaItems,
|
||||
PlaybackOrder playbackOrder)
|
||||
{
|
||||
Option<PlayoutProgramScheduleAnchor> maybeAnchor = playout.ProgramScheduleAnchors.FirstOrDefault(
|
||||
a => a.ProgramScheduleId == playout.ProgramScheduleId
|
||||
&& a.CollectionType == collectionKey.CollectionType
|
||||
&& a.CollectionId == collectionKey.CollectionId
|
||||
&& a.MultiCollectionId == collectionKey.MultiCollectionId
|
||||
&& a.MediaItemId == collectionKey.MediaItemId);
|
||||
|
||||
CollectionEnumeratorState state = maybeAnchor.Match(
|
||||
@@ -530,8 +556,8 @@ namespace ErsatzTV.Core.Scheduling
|
||||
state);
|
||||
}
|
||||
}
|
||||
|
||||
switch (playout.ProgramSchedule.MediaCollectionPlaybackOrder)
|
||||
|
||||
switch (playbackOrder)
|
||||
{
|
||||
case PlaybackOrder.Chronological:
|
||||
return new ChronologicalMediaCollectionEnumerator(mediaItems, state);
|
||||
@@ -539,16 +565,51 @@ namespace ErsatzTV.Core.Scheduling
|
||||
return new RandomizedMediaCollectionEnumerator(mediaItems, state);
|
||||
case PlaybackOrder.Shuffle:
|
||||
return new ShuffledMediaCollectionEnumerator(
|
||||
mediaItems,
|
||||
state,
|
||||
playout.ProgramSchedule.KeepMultiPartEpisodesTogether,
|
||||
playout.ProgramSchedule.TreatCollectionsAsShows);
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<List<GroupedMediaItem>> GetGroupedMediaItemsForShuffle(
|
||||
Playout playout,
|
||||
List<MediaItem> mediaItems,
|
||||
CollectionKey collectionKey)
|
||||
{
|
||||
if (collectionKey.MultiCollectionId != null)
|
||||
{
|
||||
List<CollectionWithItems> collections = await _mediaCollectionRepository
|
||||
.GetMultiCollectionCollections(collectionKey.MultiCollectionId.Value);
|
||||
|
||||
return MultiCollectionGrouper.GroupMediaItems(collections);
|
||||
}
|
||||
|
||||
return playout.ProgramSchedule.KeepMultiPartEpisodesTogether
|
||||
? MultiPartEpisodeGrouper.GroupMediaItems(
|
||||
mediaItems,
|
||||
playout.ProgramSchedule.TreatCollectionsAsShows)
|
||||
: 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);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static string DisplayTitle(MediaItem mediaItem)
|
||||
{
|
||||
switch (mediaItem)
|
||||
@@ -595,6 +656,11 @@ namespace ErsatzTV.Core.Scheduling
|
||||
CollectionType = item.CollectionType,
|
||||
MediaItemId = item.MediaItemId
|
||||
},
|
||||
ProgramScheduleItemCollectionType.MultiCollection => new CollectionKey
|
||||
{
|
||||
CollectionType = item.CollectionType,
|
||||
MultiCollectionId = item.MultiCollectionId
|
||||
},
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(item))
|
||||
};
|
||||
|
||||
@@ -602,6 +668,7 @@ namespace ErsatzTV.Core.Scheduling
|
||||
{
|
||||
public ProgramScheduleItemCollectionType CollectionType { get; set; }
|
||||
public int? CollectionId { get; set; }
|
||||
public int? MultiCollectionId { get; set; }
|
||||
public int? MediaItemId { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
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);
|
||||
}
|
||||
|
||||
int offset = random.Next(ordered.Count);
|
||||
result.Add(
|
||||
new OrderedCollection
|
||||
{
|
||||
Index = 0,
|
||||
Items = ordered.Skip(offset).Concat(ordered.Take(offset)).ToList()
|
||||
});
|
||||
}
|
||||
|
||||
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; }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -16,16 +16,11 @@ namespace ErsatzTV.Core.Scheduling
|
||||
private IList<MediaItem> _shuffled;
|
||||
|
||||
public ShuffledMediaCollectionEnumerator(
|
||||
IList<MediaItem> mediaItems,
|
||||
CollectionEnumeratorState state,
|
||||
bool keepMultiPartEpisodesTogether,
|
||||
bool treatCollectionsAsShows)
|
||||
IList<GroupedMediaItem> mediaItems,
|
||||
CollectionEnumeratorState state)
|
||||
{
|
||||
_mediaItemCount = mediaItems.Count;
|
||||
|
||||
_mediaItems = keepMultiPartEpisodesTogether
|
||||
? MultiPartEpisodeGrouper.GroupMediaItems(mediaItems, treatCollectionsAsShows)
|
||||
: mediaItems.Map(mi => new GroupedMediaItem(mi, null)).ToList();
|
||||
_mediaItemCount = mediaItems.Sum(i => 1 + Optional(i.Additional).Flatten().Count());
|
||||
_mediaItems = mediaItems;
|
||||
|
||||
if (state.Index >= _mediaItems.Count)
|
||||
{
|
||||
@@ -78,12 +73,10 @@ 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 MultiPartEpisodeGrouper.FlattenGroups(copy, _mediaItemCount);
|
||||
return GroupedMediaItem.FlattenGroups(copy, _mediaItemCount);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace ErsatzTV.Infrastructure.Data.Configurations
|
||||
{
|
||||
public class MultiCollectionConfiguration : IEntityTypeConfiguration<MultiCollection>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<MultiCollection> builder)
|
||||
{
|
||||
builder.ToTable("MultiCollection");
|
||||
|
||||
builder.HasMany(m => m.Collections)
|
||||
.WithMany(m => m.MultiCollections)
|
||||
.UsingEntity<MultiCollectionItem>(
|
||||
j => j.HasOne(mci => mci.Collection)
|
||||
.WithMany(c => c.MultiCollectionItems)
|
||||
.HasForeignKey(mci => mci.CollectionId)
|
||||
.OnDelete(DeleteBehavior.Cascade),
|
||||
j => j.HasOne(mci => mci.MultiCollection)
|
||||
.WithMany(mc => mc.MultiCollectionItems)
|
||||
.HasForeignKey(mci => mci.MultiCollectionId)
|
||||
.OnDelete(DeleteBehavior.Cascade),
|
||||
j => j.HasKey(mci => new { mci.MultiCollectionId, mci.CollectionId }));
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace ErsatzTV.Infrastructure.Data.Configurations
|
||||
{
|
||||
public class MultiCollectionItemConfiguration : IEntityTypeConfiguration<MultiCollectionItem>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<MultiCollectionItem> builder) => builder.ToTable("MultiCollectionItem");
|
||||
}
|
||||
}
|
||||
+6
@@ -19,6 +19,12 @@ namespace ErsatzTV.Infrastructure.Data.Configurations
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired(false);
|
||||
|
||||
builder.HasOne(i => i.MultiCollection)
|
||||
.WithMany()
|
||||
.HasForeignKey(i => i.MultiCollectionId)
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired(false);
|
||||
|
||||
builder.HasOne(i => i.MediaItem)
|
||||
.WithMany()
|
||||
.HasForeignKey(i => i.MediaItemId)
|
||||
|
||||
@@ -21,6 +21,12 @@ namespace ErsatzTV.Infrastructure.Data.Configurations
|
||||
.HasForeignKey(i => i.MediaItemId)
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired(false);
|
||||
|
||||
builder.HasOne(i => i.MultiCollection)
|
||||
.WithMany()
|
||||
.HasForeignKey(i => i.MultiCollectionId)
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ using System.Threading.Tasks;
|
||||
using Dapper;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Scheduling;
|
||||
using ErsatzTV.Infrastructure.Extensions;
|
||||
using LanguageExt;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using static LanguageExt.Prelude;
|
||||
@@ -50,16 +52,113 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
return result.Distinct().ToList();
|
||||
}
|
||||
|
||||
public async Task<List<MediaItem>> GetMultiCollectionItems(int id)
|
||||
{
|
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||
|
||||
var result = new List<MediaItem>();
|
||||
|
||||
Option<MultiCollection> maybeMultiCollection = await dbContext.MultiCollections
|
||||
.Include(mc => mc.Collections)
|
||||
.SelectOneAsync(mc => mc.Id, mc => mc.Id == id);
|
||||
|
||||
foreach (MultiCollection multiCollection in maybeMultiCollection)
|
||||
{
|
||||
foreach (int collectionId in multiCollection.Collections.Map(c => c.Id))
|
||||
{
|
||||
result.AddRange(await GetMovieItems(dbContext, collectionId));
|
||||
result.AddRange(await GetShowItems(dbContext, collectionId));
|
||||
result.AddRange(await GetSeasonItems(dbContext, collectionId));
|
||||
result.AddRange(await GetEpisodeItems(dbContext, collectionId));
|
||||
result.AddRange(await GetArtistItems(dbContext, collectionId));
|
||||
result.AddRange(await GetMusicVideoItems(dbContext, collectionId));
|
||||
}
|
||||
}
|
||||
|
||||
return result.Distinct().ToList();
|
||||
}
|
||||
|
||||
public async Task<List<CollectionWithItems>> GetMultiCollectionCollections(int id)
|
||||
{
|
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||
|
||||
var result = new List<CollectionWithItems>();
|
||||
|
||||
Option<MultiCollection> maybeMultiCollection = await dbContext.MultiCollections
|
||||
.Include(mc => mc.Collections)
|
||||
.Include(mc => mc.MultiCollectionItems)
|
||||
.ThenInclude(mci => mci.Collection)
|
||||
.SelectOneAsync(mc => mc.Id, mc => mc.Id == id);
|
||||
|
||||
foreach (MultiCollection multiCollection in maybeMultiCollection)
|
||||
{
|
||||
foreach (MultiCollectionItem multiCollectionItem in multiCollection.MultiCollectionItems)
|
||||
{
|
||||
List<MediaItem> items = await GetItems(multiCollectionItem.CollectionId);
|
||||
|
||||
if (multiCollectionItem.Collection.UseCustomPlaybackOrder)
|
||||
{
|
||||
foreach (Collection collection in await GetCollectionWithCollectionItemsUntracked(
|
||||
multiCollectionItem.CollectionId))
|
||||
{
|
||||
var sortedItems = collection.CollectionItems
|
||||
.OrderBy(ci => ci.CustomIndex)
|
||||
.Map(ci => items.First(i => i.Id == ci.MediaItemId))
|
||||
.ToList();
|
||||
|
||||
result.Add(
|
||||
new CollectionWithItems(
|
||||
multiCollectionItem.CollectionId,
|
||||
sortedItems,
|
||||
multiCollectionItem.ScheduleAsGroup,
|
||||
multiCollectionItem.PlaybackOrder,
|
||||
multiCollectionItem.Collection.UseCustomPlaybackOrder));
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
result.Add(
|
||||
new CollectionWithItems(
|
||||
multiCollectionItem.CollectionId,
|
||||
items,
|
||||
multiCollectionItem.ScheduleAsGroup,
|
||||
multiCollectionItem.PlaybackOrder,
|
||||
multiCollectionItem.Collection.UseCustomPlaybackOrder));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// remove duplicate items from ungrouped collections
|
||||
var toRemoveFrom = result.Filter(c => !c.ScheduleAsGroup).ToList();
|
||||
var toRemove = result.Filter(c => c.ScheduleAsGroup)
|
||||
.SelectMany(c => c.MediaItems.Map(i => i.Id))
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
foreach (CollectionWithItems collection in toRemoveFrom)
|
||||
{
|
||||
collection.MediaItems.RemoveAll(mi => toRemove.Contains(mi.Id));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public Task<List<int>> PlayoutIdsUsingCollection(int collectionId) =>
|
||||
_dbConnection.QueryAsync<int>(
|
||||
@"SELECT DISTINCT p.Id
|
||||
FROM Playout p
|
||||
INNER JOIN ProgramSchedule PS on p.ProgramScheduleId = PS.Id
|
||||
INNER JOIN ProgramScheduleItem PSI on p.Anchor_NextScheduleItemId = PSI.Id
|
||||
WHERE PSI.CollectionId = @CollectionId",
|
||||
@"SELECT DISTINCT p.PlayoutId
|
||||
FROM PlayoutProgramScheduleAnchor p
|
||||
WHERE p.CollectionId = @CollectionId",
|
||||
new { CollectionId = collectionId })
|
||||
.Map(result => result.ToList());
|
||||
|
||||
public Task<List<int>> PlayoutIdsUsingMultiCollection(int multiCollectionId) =>
|
||||
_dbConnection.QueryAsync<int>(
|
||||
@"SELECT DISTINCT p.PlayoutId
|
||||
FROM PlayoutProgramScheduleAnchor p
|
||||
WHERE p.MultiCollectionId = @MultiCollectionId",
|
||||
new { MultiCollectionId = multiCollectionId })
|
||||
.Map(result => result.ToList());
|
||||
|
||||
public Task<bool> IsCustomPlaybackOrder(int collectionId) =>
|
||||
_dbConnection.QuerySingleAsync<bool>(
|
||||
@"SELECT IFNULL(MIN(UseCustomPlaybackOrder), 0) FROM Collection WHERE Id = @CollectionId",
|
||||
|
||||
@@ -58,6 +58,7 @@ namespace ErsatzTV.Infrastructure.Data
|
||||
public DbSet<EmbyEpisode> EmbyEpisodes { get; set; }
|
||||
public DbSet<Collection> Collections { get; set; }
|
||||
public DbSet<CollectionItem> CollectionItems { get; set; }
|
||||
public DbSet<MultiCollection> MultiCollections { get; set; }
|
||||
public DbSet<ProgramSchedule> ProgramSchedules { get; set; }
|
||||
public DbSet<ProgramScheduleItem> ProgramScheduleItems { get; set; }
|
||||
public DbSet<Playout> Playouts { get; set; }
|
||||
|
||||
@@ -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.7" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="5.0.7">
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="5.0.9" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="5.0.9">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="5.0.7" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="5.0.9" />
|
||||
<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>
|
||||
|
||||
|
||||
+3064
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,125 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
namespace ErsatzTV.Infrastructure.Migrations
|
||||
{
|
||||
public partial class Add_MultiCollection : Migration
|
||||
{
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "MultiCollectionId",
|
||||
table: "ProgramScheduleItem",
|
||||
type: "INTEGER",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "MultiCollectionId",
|
||||
table: "PlayoutProgramScheduleAnchor",
|
||||
type: "INTEGER",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "MultiCollection",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "INTEGER", nullable: false)
|
||||
.Annotation("Sqlite:Autoincrement", true),
|
||||
Name = table.Column<string>(type: "TEXT", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_MultiCollection", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "MultiCollectionItem",
|
||||
columns: table => new
|
||||
{
|
||||
MultiCollectionId = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
CollectionId = 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_MultiCollectionItem", x => new { x.MultiCollectionId, x.CollectionId });
|
||||
table.ForeignKey(
|
||||
name: "FK_MultiCollectionItem_Collection_CollectionId",
|
||||
column: x => x.CollectionId,
|
||||
principalTable: "Collection",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
table.ForeignKey(
|
||||
name: "FK_MultiCollectionItem_MultiCollection_MultiCollectionId",
|
||||
column: x => x.MultiCollectionId,
|
||||
principalTable: "MultiCollection",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ProgramScheduleItem_MultiCollectionId",
|
||||
table: "ProgramScheduleItem",
|
||||
column: "MultiCollectionId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_PlayoutProgramScheduleAnchor_MultiCollectionId",
|
||||
table: "PlayoutProgramScheduleAnchor",
|
||||
column: "MultiCollectionId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_MultiCollectionItem_CollectionId",
|
||||
table: "MultiCollectionItem",
|
||||
column: "CollectionId");
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_PlayoutProgramScheduleAnchor_MultiCollection_MultiCollectionId",
|
||||
table: "PlayoutProgramScheduleAnchor",
|
||||
column: "MultiCollectionId",
|
||||
principalTable: "MultiCollection",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_ProgramScheduleItem_MultiCollection_MultiCollectionId",
|
||||
table: "ProgramScheduleItem",
|
||||
column: "MultiCollectionId",
|
||||
principalTable: "MultiCollection",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
}
|
||||
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_PlayoutProgramScheduleAnchor_MultiCollection_MultiCollectionId",
|
||||
table: "PlayoutProgramScheduleAnchor");
|
||||
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_ProgramScheduleItem_MultiCollection_MultiCollectionId",
|
||||
table: "ProgramScheduleItem");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "MultiCollectionItem");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "MultiCollection");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_ProgramScheduleItem_MultiCollectionId",
|
||||
table: "ProgramScheduleItem");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_PlayoutProgramScheduleAnchor_MultiCollectionId",
|
||||
table: "PlayoutProgramScheduleAnchor");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "MultiCollectionId",
|
||||
table: "ProgramScheduleItem");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "MultiCollectionId",
|
||||
table: "PlayoutProgramScheduleAnchor");
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user