Compare commits
29
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 | ||
|
|
1c1c1e7812 | ||
|
|
5f802c7484 | ||
|
|
7a06ac71e2 | ||
|
|
3b9b8796b9 | ||
|
|
a72d91507e | ||
|
|
45bfbfc179 |
@@ -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
|
||||
@@ -11,6 +11,9 @@ jobs:
|
||||
- os: ubuntu-latest
|
||||
kind: linux
|
||||
target: linux-x64
|
||||
- os: ubuntu-latest
|
||||
kind: linux
|
||||
target: linux-arm
|
||||
- os: windows-latest
|
||||
kind: windows
|
||||
target: win-x64
|
||||
@@ -39,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
|
||||
|
||||
+55
-1
@@ -5,6 +5,55 @@ 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
|
||||
|
||||
### Fixed
|
||||
- Fix bug preventing ingestion of local movies with fallback metadata (without NFO files)
|
||||
- Fix extra spaces in titles of local movies with fallback metadata (without NFO files)
|
||||
|
||||
## [0.0.49-prealpha] - 2021-07-11
|
||||
### Added
|
||||
- Include audio language metadata in all streaming modes
|
||||
@@ -497,7 +546,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
||||
- Initial release to facilitate testing outside of Docker.
|
||||
|
||||
|
||||
[Unreleased]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.49-prealpha...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.47-prealpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.46-prealpha...v0.0.47-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,32 @@ 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]
|
||||
[TestCase("Something (2021).mkv", "Something")]
|
||||
[TestCase("Something Else (2021).mkv", "Something Else")]
|
||||
public void GetFallbackMetadata_Should_Set_Proper_Movie_Title(string path, string expectedTitle)
|
||||
{
|
||||
MovieMetadata metadata = _fallbackMetadataProvider.GetFallbackMetadata(
|
||||
new Movie
|
||||
{
|
||||
LibraryPath = new LibraryPath(),
|
||||
MediaVersions = new List<MediaVersion>
|
||||
{
|
||||
new()
|
||||
{
|
||||
MediaFiles = new List<MediaFile>
|
||||
{
|
||||
new() { Path = path }
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
metadata.Should().NotBeNull();
|
||||
metadata.Title.Should().Be(expectedTitle);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -161,13 +161,15 @@ namespace ErsatzTV.Core.Metadata
|
||||
Match match = Regex.Match(fileName, PATTERN);
|
||||
if (match.Success)
|
||||
{
|
||||
metadata.Title = match.Groups[1].Value;
|
||||
metadata.Title = match.Groups[1].Value.Trim();
|
||||
metadata.Year = int.Parse(match.Groups[2].Value);
|
||||
metadata.ReleaseDate = new DateTime(int.Parse(match.Groups[2].Value), 1, 1);
|
||||
metadata.Genres = new List<Genre>();
|
||||
metadata.Tags = new List<Tag>();
|
||||
metadata.Studios = new List<Studio>();
|
||||
metadata.Actors = new List<Actor>();
|
||||
metadata.Directors = new List<Director>();
|
||||
metadata.Writers = new List<Writer>();
|
||||
metadata.DateUpdated = DateTime.UtcNow;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
Generated
+2979
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user