Compare commits
26 Commits
v0.7.2-bet
...
v0.7.3-bet
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3d821043bb | ||
|
|
e69c58e615 | ||
|
|
a21b6f9f4e | ||
|
|
99b8038852 | ||
|
|
ef8ca9f8c6 | ||
|
|
d9186df157 | ||
|
|
aca6bfb0bb | ||
|
|
587fc3a98f | ||
|
|
ab1c67e60e | ||
|
|
e271f43066 | ||
|
|
6bf8feb26e | ||
|
|
ffd66f6a21 | ||
|
|
3b135df4c1 | ||
|
|
4369d04940 | ||
|
|
faaa78fed7 | ||
|
|
6bea1660ea | ||
|
|
8d46676c25 | ||
|
|
4c75e638a2 | ||
|
|
dd73a3803a | ||
|
|
f6c345d7cf | ||
|
|
585b56a668 | ||
|
|
f18f3b4f35 | ||
|
|
eb7871a048 | ||
|
|
000fc78fd3 | ||
|
|
ba676ef956 | ||
|
|
36ea88e2d6 |
8
.github/workflows/artifacts.yml
vendored
8
.github/workflows/artifacts.yml
vendored
@@ -33,10 +33,10 @@ jobs:
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- os: macos-latest
|
||||
- os: macos-11
|
||||
kind: macOS
|
||||
target: osx-x64
|
||||
- os: macos-latest
|
||||
- os: macos-11
|
||||
kind: macOS
|
||||
target: osx-arm64
|
||||
steps:
|
||||
@@ -83,8 +83,8 @@ jobs:
|
||||
shell: bash
|
||||
run: |
|
||||
sed -i '' '/Scanner/d' ErsatzTV/ErsatzTV.csproj
|
||||
dotnet publish ErsatzTV.Scanner/ErsatzTV.Scanner.csproj --framework net7.0 --runtime "${{ matrix.target }}" -c Release -o publish -p:InformationalVersion="${{ inputs.release_version }}-${{ matrix.target }}" -p:EnableCompressionInSingleFile=true -p:DebugType=Embedded -p:PublishSingleFile=true --self-contained true
|
||||
dotnet publish ErsatzTV/ErsatzTV.csproj --framework net7.0 --runtime "${{ matrix.target }}" -c Release -o publish -p:InformationalVersion="${{ inputs.release_version }}-${{ matrix.target }}" -p:EnableCompressionInSingleFile=true -p:DebugType=Embedded -p:PublishSingleFile=true --self-contained true
|
||||
dotnet publish ErsatzTV.Scanner/ErsatzTV.Scanner.csproj --framework net7.0 --runtime "${{ matrix.target }}" -c Release -o publish -p:InformationalVersion="${{ inputs.release_version }}-${{ matrix.target }}" -p:EnableCompressionInSingleFile=false -p:DebugType=Embedded -p:PublishSingleFile=true --self-contained true
|
||||
dotnet publish ErsatzTV/ErsatzTV.csproj --framework net7.0 --runtime "${{ matrix.target }}" -c Release -o publish -p:InformationalVersion="${{ inputs.release_version }}-${{ matrix.target }}" -p:EnableCompressionInSingleFile=false -p:DebugType=Embedded -p:PublishSingleFile=true --self-contained true
|
||||
|
||||
- name: Bundle
|
||||
shell: bash
|
||||
|
||||
2
.github/workflows/pr.yml
vendored
2
.github/workflows/pr.yml
vendored
@@ -63,7 +63,7 @@ jobs:
|
||||
- name: Test
|
||||
run: dotnet test --no-restore --verbosity normal
|
||||
build_and_test_mac:
|
||||
runs-on: macos-latest
|
||||
runs-on: macos-11
|
||||
steps:
|
||||
- name: Get the sources
|
||||
uses: actions/checkout@v3
|
||||
|
||||
41
CHANGELOG.md
41
CHANGELOG.md
@@ -5,6 +5,44 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.7.3-beta] - 2023-01-25
|
||||
### Added
|
||||
- Attempt to release memory periodically
|
||||
- Add OpenID Connect (OIDC) support (e.g. Keycloak, Authelia, Auth0)
|
||||
- This only protects the management UI; all streaming endpoints will continue to allow anonymous access
|
||||
- This can be configured with the following env vars (note the double underscore separator `__`)
|
||||
- `OIDC__AUTHORITY`
|
||||
- `OIDC__CLIENTID`
|
||||
- `OIDC__CLIENTSECRET`
|
||||
- `OIDC__LOGOUTURI` (optional, needed for Auth0, use `https://{auth0-domain}/v2/logout?client_id={auth0-client-id}` with proper values for domain and client-id)
|
||||
- Add *experimental* alternate schedule system
|
||||
- This allows a single playout to dynamically select a schedule based on date criteria, for example:
|
||||
- Weekday vs weekend schedules
|
||||
- Summer vs fall schedules
|
||||
- Shark week schedules
|
||||
- Alternate schedules can be managed by clicking the calendar icon in the playout list
|
||||
- Playouts contain a prioritized (top to bottom) list of alternate schedules
|
||||
- Whenever a playout is built for a given day, ErsatzTV will check for a matching schedule from top to bottom
|
||||
- A given day must match all alternate schedule parameters; wildcards (`*any*`) will always match
|
||||
- Day of week
|
||||
- Day of month
|
||||
- Month
|
||||
- The lowest priority (bottom) item will always match all parameters, and can be considered a "default" or "fallback" schedule
|
||||
|
||||
### Fixed
|
||||
- Fix schedule editor crashing due to bad music video artist data
|
||||
- Fix bug where playouts would not maintain smart collection progress on schedules that use multiple smart collections
|
||||
- Fix library scanning on osx-arm64
|
||||
- Fix ability to remove some media server libraries from ErsatzTV
|
||||
|
||||
### Changed
|
||||
- Always use software pipeline for error display
|
||||
- This ensures errors will display even when hardware acceleration is misconfigured
|
||||
- Call scanner process only when scanning is required based on library refresh interval
|
||||
- Use lower process priority for scanner process with unforced (automatic) library scans
|
||||
- Disable V2 UI and APIs by default
|
||||
- V2 UI can be re-enabled by setting the env var `ETV_UI_V2` to any value
|
||||
|
||||
## [0.7.2-beta] - 2023-01-05
|
||||
### Fixed
|
||||
- Fix VAAPI encoding in docker by switching to non-free driver
|
||||
@@ -1463,7 +1501,8 @@ 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.7.2-beta...HEAD
|
||||
[Unreleased]: https://github.com/jasongdove/ErsatzTV/compare/v0.7.3-beta...HEAD
|
||||
[0.7.2-beta]: https://github.com/jasongdove/ErsatzTV/compare/v0.7.2-beta...v0.7.3-beta
|
||||
[0.7.2-beta]: https://github.com/jasongdove/ErsatzTV/compare/v0.7.1-beta...v0.7.2-beta
|
||||
[0.7.1-beta]: https://github.com/jasongdove/ErsatzTV/compare/v0.7.0-beta...v0.7.1-beta
|
||||
[0.7.0-beta]: https://github.com/jasongdove/ErsatzTV/compare/v0.6.9-beta...v0.7.0-beta
|
||||
|
||||
@@ -1,22 +1,44 @@
|
||||
using ErsatzTV.Application.MediaItems;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using static ErsatzTV.Application.MediaItems.Mapper;
|
||||
|
||||
namespace ErsatzTV.Application.Artists;
|
||||
|
||||
public class GetAllArtistsHandler : IRequestHandler<GetAllArtists, List<NamedMediaItemViewModel>>
|
||||
{
|
||||
private readonly IArtistRepository _artistRepository;
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||
|
||||
public GetAllArtistsHandler(IArtistRepository artistRepository) => _artistRepository = artistRepository;
|
||||
public GetAllArtistsHandler(IDbContextFactory<TvContext> dbContextFactory)
|
||||
{
|
||||
_dbContextFactory = dbContextFactory;
|
||||
}
|
||||
|
||||
public Task<List<NamedMediaItemViewModel>> Handle(
|
||||
public async Task<List<NamedMediaItemViewModel>> Handle(
|
||||
GetAllArtists request,
|
||||
CancellationToken cancellationToken) =>
|
||||
_artistRepository.GetAllArtists()
|
||||
.Map(
|
||||
list => list.Filter(
|
||||
a => !string.IsNullOrWhiteSpace(
|
||||
a.ArtistMetadata.HeadOrNone().Match(am => am.Title, () => string.Empty))))
|
||||
.Map(list => list.Map(ProjectToViewModel).ToList());
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
List<Artist> allArtists = await dbContext.Artists
|
||||
.AsNoTracking()
|
||||
.Include(a => a.ArtistMetadata)
|
||||
.ToListAsync(cancellationToken: cancellationToken);
|
||||
|
||||
return allArtists.Bind(a => ProjectArtist(a)).ToList();
|
||||
}
|
||||
|
||||
private static Option<NamedMediaItemViewModel> ProjectArtist(Artist a)
|
||||
{
|
||||
foreach (ArtistMetadata metadata in a.ArtistMetadata.HeadOrNone())
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(metadata.Title))
|
||||
{
|
||||
return ProjectToViewModel(a);
|
||||
}
|
||||
}
|
||||
|
||||
return None;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,13 +15,13 @@ namespace ErsatzTV.Application.Channels;
|
||||
public class UpdateChannelHandler : IRequestHandler<UpdateChannel, Either<BaseError, ChannelViewModel>>
|
||||
{
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||
private readonly ChannelWriter<ISubtitleWorkerRequest> _ffmpegWorkerChannel;
|
||||
private readonly ChannelWriter<IBackgroundServiceRequest> _workerChannel;
|
||||
|
||||
public UpdateChannelHandler(
|
||||
ChannelWriter<ISubtitleWorkerRequest> ffmpegWorkerChannel,
|
||||
ChannelWriter<IBackgroundServiceRequest> workerChannel,
|
||||
IDbContextFactory<TvContext> dbContextFactory)
|
||||
{
|
||||
_ffmpegWorkerChannel = ffmpegWorkerChannel;
|
||||
_workerChannel = workerChannel;
|
||||
_dbContextFactory = dbContextFactory;
|
||||
}
|
||||
|
||||
@@ -85,7 +85,7 @@ public class UpdateChannelHandler : IRequestHandler<UpdateChannel, Either<BaseEr
|
||||
|
||||
foreach (Playout playout in maybePlayout)
|
||||
{
|
||||
await _ffmpegWorkerChannel.WriteAsync(new ExtractEmbeddedSubtitles(playout.Id));
|
||||
await _workerChannel.WriteAsync(new ExtractEmbeddedSubtitles(playout.Id));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
public record GetChannelNameByPlayoutId(int PlayoutId) : IRequest<Option<string>>;
|
||||
@@ -0,0 +1,24 @@
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Extensions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
public class GetChannelNameByPlayoutIdHandler : IRequestHandler<GetChannelNameByPlayoutId, Option<string>>
|
||||
{
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||
|
||||
public GetChannelNameByPlayoutIdHandler(IDbContextFactory<TvContext> dbContextFactory)
|
||||
{
|
||||
_dbContextFactory = dbContextFactory;
|
||||
}
|
||||
|
||||
public async Task<Option<string>> Handle(GetChannelNameByPlayoutId request, CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
return await dbContext.Playouts
|
||||
.Include(p => p.Channel)
|
||||
.SelectOneAsync(p => p.Id, p => p.Id == request.PlayoutId)
|
||||
.MapT(p => p.Channel.Name);
|
||||
}
|
||||
}
|
||||
@@ -1,19 +1,26 @@
|
||||
using System.Threading.Channels;
|
||||
using ErsatzTV.Application.Libraries;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.FFmpeg.Runtime;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Extensions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ErsatzTV.Application.Emby;
|
||||
|
||||
public class CallEmbyLibraryScannerHandler : CallLibraryScannerHandler,
|
||||
public class CallEmbyLibraryScannerHandler : CallLibraryScannerHandler<ISynchronizeEmbyLibraryById>,
|
||||
IRequestHandler<ForceSynchronizeEmbyLibraryById, Either<BaseError, string>>,
|
||||
IRequestHandler<SynchronizeEmbyLibraryByIdIfNeeded, Either<BaseError, string>>
|
||||
{
|
||||
public CallEmbyLibraryScannerHandler(
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
IConfigElementRepository configElementRepository,
|
||||
ChannelWriter<ISearchIndexBackgroundServiceRequest> channel,
|
||||
IMediator mediator,
|
||||
IRuntimeInfo runtimeInfo)
|
||||
: base(channel, mediator, runtimeInfo)
|
||||
: base(dbContextFactory, configElementRepository, channel, mediator, runtimeInfo)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -29,10 +36,18 @@ public class CallEmbyLibraryScannerHandler : CallLibraryScannerHandler,
|
||||
ISynchronizeEmbyLibraryById request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Validation<BaseError, string> validation = Validate();
|
||||
Validation<BaseError, string> validation = await Validate(request);
|
||||
return await validation.Match(
|
||||
scanner => PerformScan(scanner, request, cancellationToken),
|
||||
error => Task.FromResult<Either<BaseError, string>>(error.Join()));
|
||||
error =>
|
||||
{
|
||||
foreach (ScanIsNotRequired scanIsNotRequired in error.OfType<ScanIsNotRequired>())
|
||||
{
|
||||
return Task.FromResult<Either<BaseError, string>>(scanIsNotRequired);
|
||||
}
|
||||
|
||||
return Task.FromResult<Either<BaseError, string>>(error.Join());
|
||||
});
|
||||
}
|
||||
|
||||
private async Task<Either<BaseError, string>> PerformScan(
|
||||
@@ -52,4 +67,27 @@ public class CallEmbyLibraryScannerHandler : CallLibraryScannerHandler,
|
||||
|
||||
return await base.PerformScan(scanner, arguments, cancellationToken);
|
||||
}
|
||||
|
||||
protected override async Task<DateTimeOffset> GetLastScan(
|
||||
TvContext dbContext,
|
||||
ISynchronizeEmbyLibraryById request)
|
||||
{
|
||||
return await dbContext.EmbyLibraries
|
||||
.SelectOneAsync(l => l.Id, l => l.Id == request.EmbyLibraryId)
|
||||
.Match(l => l.LastScan ?? SystemTime.MinValueUtc, () => SystemTime.MaxValueUtc);
|
||||
}
|
||||
|
||||
protected override bool ScanIsRequired(
|
||||
DateTimeOffset lastScan,
|
||||
int libraryRefreshInterval,
|
||||
ISynchronizeEmbyLibraryById request)
|
||||
{
|
||||
if (lastScan == SystemTime.MaxValueUtc)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
DateTimeOffset nextScan = lastScan + TimeSpan.FromHours(libraryRefreshInterval);
|
||||
return request.ForceScan || (libraryRefreshInterval > 0 && nextScan < DateTimeOffset.Now);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,7 +52,7 @@ public class UpdateFFmpegSettingsHandler : IRequestHandler<UpdateFFmpegSettings,
|
||||
UseShellExecute = false
|
||||
};
|
||||
|
||||
var test = new Process
|
||||
using var test = new Process
|
||||
{
|
||||
StartInfo = startInfo
|
||||
};
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
namespace ErsatzTV.Application;
|
||||
|
||||
public interface ISubtitleWorkerRequest
|
||||
{
|
||||
}
|
||||
@@ -1,19 +1,26 @@
|
||||
using System.Threading.Channels;
|
||||
using ErsatzTV.Application.Libraries;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.FFmpeg.Runtime;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Extensions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ErsatzTV.Application.Jellyfin;
|
||||
|
||||
public class CallJellyfinLibraryScannerHandler : CallLibraryScannerHandler,
|
||||
public class CallJellyfinLibraryScannerHandler : CallLibraryScannerHandler<ISynchronizeJellyfinLibraryById>,
|
||||
IRequestHandler<ForceSynchronizeJellyfinLibraryById, Either<BaseError, string>>,
|
||||
IRequestHandler<SynchronizeJellyfinLibraryByIdIfNeeded, Either<BaseError, string>>
|
||||
{
|
||||
public CallJellyfinLibraryScannerHandler(
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
IConfigElementRepository configElementRepository,
|
||||
ChannelWriter<ISearchIndexBackgroundServiceRequest> channel,
|
||||
IMediator mediator,
|
||||
IRuntimeInfo runtimeInfo)
|
||||
: base(channel, mediator, runtimeInfo)
|
||||
: base(dbContextFactory, configElementRepository, channel, mediator, runtimeInfo)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -29,10 +36,18 @@ public class CallJellyfinLibraryScannerHandler : CallLibraryScannerHandler,
|
||||
ISynchronizeJellyfinLibraryById request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Validation<BaseError, string> validation = Validate();
|
||||
Validation<BaseError, string> validation = await Validate(request);
|
||||
return await validation.Match(
|
||||
scanner => PerformScan(scanner, request, cancellationToken),
|
||||
error => Task.FromResult<Either<BaseError, string>>(error.Join()));
|
||||
error =>
|
||||
{
|
||||
foreach (ScanIsNotRequired scanIsNotRequired in error.OfType<ScanIsNotRequired>())
|
||||
{
|
||||
return Task.FromResult<Either<BaseError, string>>(scanIsNotRequired);
|
||||
}
|
||||
|
||||
return Task.FromResult<Either<BaseError, string>>(error.Join());
|
||||
});
|
||||
}
|
||||
|
||||
private async Task<Either<BaseError, string>> PerformScan(
|
||||
@@ -52,4 +67,27 @@ public class CallJellyfinLibraryScannerHandler : CallLibraryScannerHandler,
|
||||
|
||||
return await base.PerformScan(scanner, arguments, cancellationToken);
|
||||
}
|
||||
|
||||
protected override async Task<DateTimeOffset> GetLastScan(
|
||||
TvContext dbContext,
|
||||
ISynchronizeJellyfinLibraryById request)
|
||||
{
|
||||
return await dbContext.JellyfinLibraries
|
||||
.SelectOneAsync(l => l.Id, l => l.Id == request.JellyfinLibraryId)
|
||||
.Match(l => l.LastScan ?? SystemTime.MinValueUtc, () => SystemTime.MaxValueUtc);
|
||||
}
|
||||
|
||||
protected override bool ScanIsRequired(
|
||||
DateTimeOffset lastScan,
|
||||
int libraryRefreshInterval,
|
||||
ISynchronizeJellyfinLibraryById request)
|
||||
{
|
||||
if (lastScan == SystemTime.MaxValueUtc)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
DateTimeOffset nextScan = lastScan + TimeSpan.FromHours(libraryRefreshInterval);
|
||||
return request.ForceScan || (libraryRefreshInterval > 0 && nextScan < DateTimeOffset.Now);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,9 +3,14 @@ using System.Threading.Channels;
|
||||
using CliWrap;
|
||||
using ErsatzTV.Application.Search;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.MediaSources;
|
||||
using ErsatzTV.Core.Metadata;
|
||||
using ErsatzTV.FFmpeg.Runtime;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Newtonsoft.Json;
|
||||
using Serilog;
|
||||
using Serilog.Events;
|
||||
@@ -13,18 +18,24 @@ using Serilog.Formatting.Compact.Reader;
|
||||
|
||||
namespace ErsatzTV.Application.Libraries;
|
||||
|
||||
public abstract class CallLibraryScannerHandler
|
||||
public abstract class CallLibraryScannerHandler<TRequest>
|
||||
{
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||
private readonly IConfigElementRepository _configElementRepository;
|
||||
private readonly ChannelWriter<ISearchIndexBackgroundServiceRequest> _channel;
|
||||
private readonly IMediator _mediator;
|
||||
private readonly IRuntimeInfo _runtimeInfo;
|
||||
private string _libraryName;
|
||||
|
||||
protected CallLibraryScannerHandler(
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
IConfigElementRepository configElementRepository,
|
||||
ChannelWriter<ISearchIndexBackgroundServiceRequest> channel,
|
||||
IMediator mediator,
|
||||
IRuntimeInfo runtimeInfo)
|
||||
{
|
||||
_dbContextFactory = dbContextFactory;
|
||||
_configElementRepository = configElementRepository;
|
||||
_channel = channel;
|
||||
_mediator = mediator;
|
||||
_runtimeInfo = runtimeInfo;
|
||||
@@ -131,8 +142,25 @@ public abstract class CallLibraryScannerHandler
|
||||
}
|
||||
}
|
||||
|
||||
protected Validation<BaseError, string> Validate()
|
||||
protected abstract Task<DateTimeOffset> GetLastScan(TvContext dbContext, TRequest request);
|
||||
protected abstract bool ScanIsRequired(DateTimeOffset lastScan, int libraryRefreshInterval, TRequest request);
|
||||
|
||||
protected async Task<Validation<BaseError, string>> Validate(TRequest request)
|
||||
{
|
||||
int libraryRefreshInterval = await _configElementRepository
|
||||
.GetValue<int>(ConfigElementKey.LibraryRefreshInterval)
|
||||
.IfNoneAsync(0);
|
||||
|
||||
libraryRefreshInterval = Math.Clamp(libraryRefreshInterval, 0, 999_999);
|
||||
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
|
||||
|
||||
DateTimeOffset lastScan = await GetLastScan(dbContext, request);
|
||||
if (!ScanIsRequired(lastScan, libraryRefreshInterval, request))
|
||||
{
|
||||
return new ScanIsNotRequired();
|
||||
}
|
||||
|
||||
string executable = _runtimeInfo.IsOSPlatform(OSPlatform.Windows)
|
||||
? "ErsatzTV.Scanner.exe"
|
||||
: "ErsatzTV.Scanner";
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace ErsatzTV.Application.Maintenance;
|
||||
|
||||
public record ReleaseMemory(bool ForceAggressive) : IRequest<Unit>, IBackgroundServiceRequest
|
||||
{
|
||||
public DateTimeOffset RequestTime = DateTimeOffset.Now;
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
using ErsatzTV.Core.FFmpeg;
|
||||
using ErsatzTV.Core.Interfaces.FFmpeg;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ErsatzTV.Application.Maintenance;
|
||||
|
||||
public class ReleaseMemoryHandler : IRequestHandler<ReleaseMemory, Unit>
|
||||
{
|
||||
private static long _lastRelease;
|
||||
|
||||
private readonly IFFmpegSegmenterService _ffmpegSegmenterService;
|
||||
private readonly ILogger<ReleaseMemoryHandler> _logger;
|
||||
|
||||
public ReleaseMemoryHandler(
|
||||
IFFmpegSegmenterService ffmpegSegmenterService,
|
||||
ILogger<ReleaseMemoryHandler> logger)
|
||||
{
|
||||
_ffmpegSegmenterService = ffmpegSegmenterService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public Task<Unit> Handle(ReleaseMemory request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!request.ForceAggressive && _lastRelease > request.RequestTime.Ticks)
|
||||
{
|
||||
// we've already released since the request was created, so don't bother
|
||||
return Task.FromResult(Unit.Default);
|
||||
}
|
||||
|
||||
bool hasActiveWorkers = _ffmpegSegmenterService.SessionWorkers.Any() || FFmpegProcess.ProcessCount > 0;
|
||||
if (request.ForceAggressive || !hasActiveWorkers)
|
||||
{
|
||||
_logger.LogDebug("Starting aggressive garbage collection");
|
||||
GC.Collect(2, GCCollectionMode.Aggressive, blocking: true, compacting: true);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogDebug("Starting garbage collection");
|
||||
GC.Collect(2, GCCollectionMode.Forced, blocking: false);
|
||||
}
|
||||
|
||||
GC.WaitForPendingFinalizers();
|
||||
GC.Collect();
|
||||
|
||||
_logger.LogDebug("Completed garbage collection");
|
||||
Interlocked.Exchange(ref _lastRelease, DateTimeOffset.Now.Ticks);
|
||||
|
||||
return Task.FromResult(Unit.Default);
|
||||
}
|
||||
}
|
||||
@@ -1,19 +1,25 @@
|
||||
using System.Threading.Channels;
|
||||
using ErsatzTV.Application.Libraries;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.FFmpeg.Runtime;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ErsatzTV.Application.MediaSources;
|
||||
|
||||
public class CallLocalLibraryScannerHandler : CallLibraryScannerHandler,
|
||||
public class CallLocalLibraryScannerHandler : CallLibraryScannerHandler<IScanLocalLibrary>,
|
||||
IRequestHandler<ForceScanLocalLibrary, Either<BaseError, string>>,
|
||||
IRequestHandler<ScanLocalLibraryIfNeeded, Either<BaseError, string>>
|
||||
{
|
||||
public CallLocalLibraryScannerHandler(
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
IConfigElementRepository configElementRepository,
|
||||
ChannelWriter<ISearchIndexBackgroundServiceRequest> channel,
|
||||
IMediator mediator,
|
||||
IRuntimeInfo runtimeInfo)
|
||||
: base(channel, mediator, runtimeInfo)
|
||||
: base(dbContextFactory, configElementRepository, channel, mediator, runtimeInfo)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -27,10 +33,18 @@ public class CallLocalLibraryScannerHandler : CallLibraryScannerHandler,
|
||||
|
||||
private async Task<Either<BaseError, string>> Handle(IScanLocalLibrary request, CancellationToken cancellationToken)
|
||||
{
|
||||
Validation<BaseError, string> validation = Validate();
|
||||
Validation<BaseError, string> validation = await Validate(request);
|
||||
return await validation.Match(
|
||||
scanner => PerformScan(scanner, request, cancellationToken),
|
||||
error => Task.FromResult<Either<BaseError, string>>(error.Join()));
|
||||
error =>
|
||||
{
|
||||
foreach (ScanIsNotRequired scanIsNotRequired in error.OfType<ScanIsNotRequired>())
|
||||
{
|
||||
return Task.FromResult<Either<BaseError, string>>(scanIsNotRequired);
|
||||
}
|
||||
|
||||
return Task.FromResult<Either<BaseError, string>>(error.Join());
|
||||
});
|
||||
}
|
||||
|
||||
private async Task<Either<BaseError, string>> PerformScan(
|
||||
@@ -50,4 +64,29 @@ public class CallLocalLibraryScannerHandler : CallLibraryScannerHandler,
|
||||
|
||||
return await base.PerformScan(scanner, arguments, cancellationToken);
|
||||
}
|
||||
|
||||
protected override async Task<DateTimeOffset> GetLastScan(TvContext dbContext, IScanLocalLibrary request)
|
||||
{
|
||||
var libraryPaths = await dbContext.LibraryPaths
|
||||
.Filter(lp => lp.LibraryId == request.LibraryId)
|
||||
.ToListAsync();
|
||||
|
||||
return libraryPaths.Any()
|
||||
? libraryPaths.Min(lp => lp.LastScan ?? SystemTime.MinValueUtc)
|
||||
: SystemTime.MaxValueUtc;
|
||||
}
|
||||
|
||||
protected override bool ScanIsRequired(
|
||||
DateTimeOffset lastScan,
|
||||
int libraryRefreshInterval,
|
||||
IScanLocalLibrary request)
|
||||
{
|
||||
if (lastScan == SystemTime.MaxValueUtc)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
DateTimeOffset nextScan = lastScan + TimeSpan.FromHours(libraryRefreshInterval);
|
||||
return request.ForceScan || (libraryRefreshInterval > 0 && nextScan < DateTimeOffset.Now);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ public class BuildPlayoutHandler : IRequestHandler<BuildPlayout, Either<BaseErro
|
||||
private readonly IClient _client;
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||
private readonly IFFmpegSegmenterService _ffmpegSegmenterService;
|
||||
private readonly ChannelWriter<ISubtitleWorkerRequest> _ffmpegWorkerChannel;
|
||||
private readonly ChannelWriter<IBackgroundServiceRequest> _workerChannel;
|
||||
private readonly IPlayoutBuilder _playoutBuilder;
|
||||
|
||||
public BuildPlayoutHandler(
|
||||
@@ -25,13 +25,13 @@ public class BuildPlayoutHandler : IRequestHandler<BuildPlayout, Either<BaseErro
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
IPlayoutBuilder playoutBuilder,
|
||||
IFFmpegSegmenterService ffmpegSegmenterService,
|
||||
ChannelWriter<ISubtitleWorkerRequest> ffmpegWorkerChannel)
|
||||
ChannelWriter<IBackgroundServiceRequest> workerChannel)
|
||||
{
|
||||
_client = client;
|
||||
_dbContextFactory = dbContextFactory;
|
||||
_playoutBuilder = playoutBuilder;
|
||||
_ffmpegSegmenterService = ffmpegSegmenterService;
|
||||
_ffmpegWorkerChannel = ffmpegWorkerChannel;
|
||||
_workerChannel = workerChannel;
|
||||
}
|
||||
|
||||
public async Task<Either<BaseError, Unit>> Handle(BuildPlayout request, CancellationToken cancellationToken)
|
||||
@@ -56,7 +56,7 @@ public class BuildPlayoutHandler : IRequestHandler<BuildPlayout, Either<BaseErro
|
||||
_ffmpegSegmenterService.PlayoutUpdated(playout.Channel.Number);
|
||||
}
|
||||
|
||||
await _ffmpegWorkerChannel.WriteAsync(new ExtractEmbeddedSubtitles(playout.Id));
|
||||
await _workerChannel.WriteAsync(new ExtractEmbeddedSubtitles(playout.Id));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -75,8 +75,41 @@ public class BuildPlayoutHandler : IRequestHandler<BuildPlayout, Either<BaseErro
|
||||
dbContext.Playouts
|
||||
.Include(p => p.Channel)
|
||||
.Include(p => p.Items)
|
||||
|
||||
.Include(p => p.ProgramScheduleAlternates)
|
||||
.ThenInclude(a => a.ProgramSchedule)
|
||||
.ThenInclude(ps => ps.Items)
|
||||
.ThenInclude(psi => psi.Collection)
|
||||
.Include(p => p.ProgramScheduleAlternates)
|
||||
.ThenInclude(a => a.ProgramSchedule)
|
||||
.ThenInclude(ps => ps.Items)
|
||||
.ThenInclude(psi => psi.MediaItem)
|
||||
.Include(p => p.ProgramScheduleAlternates)
|
||||
.ThenInclude(a => a.ProgramSchedule)
|
||||
.ThenInclude(ps => ps.Items)
|
||||
.ThenInclude(psi => psi.PreRollFiller)
|
||||
.Include(p => p.ProgramScheduleAlternates)
|
||||
.ThenInclude(a => a.ProgramSchedule)
|
||||
.ThenInclude(ps => ps.Items)
|
||||
.ThenInclude(psi => psi.MidRollFiller)
|
||||
.Include(p => p.ProgramScheduleAlternates)
|
||||
.ThenInclude(a => a.ProgramSchedule)
|
||||
.ThenInclude(ps => ps.Items)
|
||||
.ThenInclude(psi => psi.PostRollFiller)
|
||||
.Include(p => p.ProgramScheduleAlternates)
|
||||
.ThenInclude(a => a.ProgramSchedule)
|
||||
.ThenInclude(ps => ps.Items)
|
||||
.ThenInclude(psi => psi.TailFiller)
|
||||
.Include(p => p.ProgramScheduleAlternates)
|
||||
.ThenInclude(a => a.ProgramSchedule)
|
||||
.ThenInclude(ps => ps.Items)
|
||||
.ThenInclude(psi => psi.FallbackFiller)
|
||||
|
||||
.Include(p => p.ProgramScheduleAnchors)
|
||||
.ThenInclude(psa => psa.EnumeratorState)
|
||||
.Include(p => p.ProgramScheduleAnchors)
|
||||
.ThenInclude(a => a.MediaItem)
|
||||
|
||||
.Include(p => p.ProgramSchedule)
|
||||
.ThenInclude(ps => ps.Items)
|
||||
.ThenInclude(psi => psi.Collection)
|
||||
@@ -98,6 +131,7 @@ public class BuildPlayoutHandler : IRequestHandler<BuildPlayout, Either<BaseErro
|
||||
.Include(p => p.ProgramSchedule)
|
||||
.ThenInclude(ps => ps.Items)
|
||||
.ThenInclude(psi => psi.FallbackFiller)
|
||||
|
||||
.SelectOneAsync(p => p.Id, p => p.Id == buildPlayout.PlayoutId)
|
||||
.Map(o => o.ToValidation<BaseError>("Playout does not exist."));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace ErsatzTV.Application.Playouts;
|
||||
|
||||
public record ReplacePlayoutAlternateSchedule(
|
||||
int Id,
|
||||
int Index,
|
||||
int ProgramScheduleId,
|
||||
List<DayOfWeek> DaysOfWeek,
|
||||
List<int> DaysOfMonth,
|
||||
List<int> MonthsOfYear);
|
||||
@@ -0,0 +1,6 @@
|
||||
using ErsatzTV.Core;
|
||||
|
||||
namespace ErsatzTV.Application.Playouts;
|
||||
|
||||
public record ReplacePlayoutAlternateScheduleItems
|
||||
(int PlayoutId, List<ReplacePlayoutAlternateSchedule> Items) : IRequest<Either<BaseError, Unit>>;
|
||||
@@ -0,0 +1,158 @@
|
||||
using System.Threading.Channels;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Scheduling;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Extensions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ErsatzTV.Application.Playouts;
|
||||
|
||||
public class ReplacePlayoutAlternateScheduleItemsHandler :
|
||||
IRequestHandler<ReplacePlayoutAlternateScheduleItems, Either<BaseError, Unit>>
|
||||
{
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||
private readonly ChannelWriter<IBackgroundServiceRequest> _channel;
|
||||
private readonly ILogger<ReplacePlayoutAlternateScheduleItemsHandler> _logger;
|
||||
|
||||
public ReplacePlayoutAlternateScheduleItemsHandler(
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
ChannelWriter<IBackgroundServiceRequest> channel,
|
||||
ILogger<ReplacePlayoutAlternateScheduleItemsHandler> logger)
|
||||
{
|
||||
_dbContextFactory = dbContextFactory;
|
||||
_channel = channel;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<Either<BaseError, Unit>> Handle(
|
||||
ReplacePlayoutAlternateScheduleItems request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// TODO: validate that items is not empty
|
||||
|
||||
try
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
Option<Playout> maybePlayout = await dbContext.Playouts
|
||||
.Include(p => p.ProgramSchedule)
|
||||
.Include(p => p.ProgramScheduleAlternates)
|
||||
.ThenInclude(p => p.ProgramSchedule)
|
||||
.SelectOneAsync(p => p.Id, p => p.Id == request.PlayoutId);
|
||||
|
||||
foreach (Playout playout in maybePlayout)
|
||||
{
|
||||
var existingScheduleMap = new Dictionary<DateTimeOffset, ProgramSchedule>();
|
||||
var daysToCheck = new List<DateTimeOffset>();
|
||||
|
||||
Option<PlayoutItem> maybeLastPlayoutItem = await dbContext.PlayoutItems
|
||||
.Filter(pi => pi.PlayoutId == request.PlayoutId)
|
||||
.OrderByDescending(pi => pi.Start)
|
||||
.FirstOrDefaultAsync(cancellationToken)
|
||||
.Map(Optional);
|
||||
|
||||
foreach (PlayoutItem lastPlayoutItem in maybeLastPlayoutItem)
|
||||
{
|
||||
DateTimeOffset start = DateTimeOffset.Now;
|
||||
daysToCheck = Enumerable.Range(0, (lastPlayoutItem.StartOffset - start).Days + 1)
|
||||
.Select(d => start.AddDays(d))
|
||||
.ToList();
|
||||
|
||||
foreach (DateTimeOffset dayToCheck in daysToCheck)
|
||||
{
|
||||
ProgramSchedule schedule = PlayoutScheduleSelector.GetProgramScheduleFor(
|
||||
playout.ProgramSchedule,
|
||||
playout.ProgramScheduleAlternates,
|
||||
dayToCheck);
|
||||
|
||||
existingScheduleMap.Add(dayToCheck, schedule);
|
||||
}
|
||||
}
|
||||
|
||||
// exclude highest index
|
||||
int maxIndex = request.Items.Map(x => x.Index).Max();
|
||||
ReplacePlayoutAlternateSchedule highest = request.Items.First(x => x.Index == maxIndex);
|
||||
|
||||
ProgramScheduleAlternate[] existing = playout.ProgramScheduleAlternates.ToArray();
|
||||
|
||||
var incoming = request.Items.Except(new[] { highest }).ToList();
|
||||
|
||||
var toAdd = incoming.Filter(x => existing.All(e => e.Id != x.Id)).ToList();
|
||||
var toRemove = existing.Filter(e => incoming.All(m => m.Id != e.Id)).ToList();
|
||||
var toUpdate = incoming.Except(toAdd).ToList();
|
||||
|
||||
playout.ProgramScheduleAlternates.RemoveAll(toRemove.Contains);
|
||||
|
||||
foreach (ReplacePlayoutAlternateSchedule add in toAdd)
|
||||
{
|
||||
playout.ProgramScheduleAlternates.Add(
|
||||
new ProgramScheduleAlternate
|
||||
{
|
||||
PlayoutId = playout.Id,
|
||||
Index = add.Index,
|
||||
ProgramScheduleId = add.ProgramScheduleId,
|
||||
DaysOfWeek = add.DaysOfWeek,
|
||||
DaysOfMonth = add.DaysOfMonth,
|
||||
MonthsOfYear = add.MonthsOfYear
|
||||
});
|
||||
}
|
||||
|
||||
foreach (ReplacePlayoutAlternateSchedule update in toUpdate)
|
||||
{
|
||||
foreach (ProgramScheduleAlternate ex in existing.Filter(x => x.Id == update.Id))
|
||||
{
|
||||
ex.Index = update.Index;
|
||||
ex.ProgramScheduleId = update.ProgramScheduleId;
|
||||
ex.DaysOfWeek = update.DaysOfWeek;
|
||||
ex.DaysOfMonth = update.DaysOfMonth;
|
||||
ex.MonthsOfYear = update.MonthsOfYear;
|
||||
}
|
||||
}
|
||||
|
||||
// save highest index directly to playout
|
||||
if (playout.ProgramScheduleId != highest.ProgramScheduleId)
|
||||
{
|
||||
playout.ProgramScheduleId = highest.ProgramScheduleId;
|
||||
}
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
|
||||
foreach (PlayoutItem _ in maybeLastPlayoutItem)
|
||||
{
|
||||
foreach (DateTimeOffset dayToCheck in daysToCheck)
|
||||
{
|
||||
ProgramSchedule schedule = PlayoutScheduleSelector.GetProgramScheduleFor(
|
||||
playout.ProgramSchedule,
|
||||
playout.ProgramScheduleAlternates,
|
||||
dayToCheck);
|
||||
|
||||
if (existingScheduleMap.TryGetValue(dayToCheck, out ProgramSchedule existingValue) &&
|
||||
existingValue.Id != schedule.Id)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Alternate schedule change detected for day {Day}, schedule {One} => {Two}; will refresh playout",
|
||||
dayToCheck,
|
||||
existingValue.Name,
|
||||
schedule.Name);
|
||||
|
||||
await _channel.WriteAsync(
|
||||
new BuildPlayout(request.PlayoutId, PlayoutBuildMode.Refresh),
|
||||
cancellationToken);
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Unit.Default;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error saving alternate schedule items");
|
||||
return BaseError.New(ex.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,16 @@ internal static class Mapper
|
||||
playoutItem.StartOffset,
|
||||
GetDisplayDuration(playoutItem.FinishOffset - playoutItem.StartOffset));
|
||||
|
||||
internal static PlayoutAlternateScheduleViewModel ProjectToViewModel(
|
||||
ProgramScheduleAlternate programScheduleAlternate) =>
|
||||
new(
|
||||
programScheduleAlternate.Id,
|
||||
programScheduleAlternate.Index,
|
||||
programScheduleAlternate.ProgramScheduleId,
|
||||
programScheduleAlternate.DaysOfWeek,
|
||||
programScheduleAlternate.DaysOfMonth,
|
||||
programScheduleAlternate.MonthsOfYear);
|
||||
|
||||
private static string GetDisplayTitle(PlayoutItem playoutItem)
|
||||
{
|
||||
switch (playoutItem.MediaItem)
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace ErsatzTV.Application.Playouts;
|
||||
|
||||
public record PlayoutAlternateScheduleViewModel(
|
||||
int Id,
|
||||
int Index,
|
||||
int ProgramScheduleId,
|
||||
ICollection<DayOfWeek> DaysOfWeek,
|
||||
ICollection<int> DaysOfMonth,
|
||||
ICollection<int> MonthsOfYear);
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace ErsatzTV.Application.Playouts;
|
||||
|
||||
public record GetPlayoutAlternateSchedules(int PlayoutId) : IRequest<List<PlayoutAlternateScheduleViewModel>>;
|
||||
@@ -0,0 +1,53 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Extensions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using static ErsatzTV.Application.Playouts.Mapper;
|
||||
|
||||
namespace ErsatzTV.Application.Playouts;
|
||||
|
||||
public class GetPlayoutAlternateSchedulesHandler :
|
||||
IRequestHandler<GetPlayoutAlternateSchedules, List<PlayoutAlternateScheduleViewModel>>
|
||||
{
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||
|
||||
public GetPlayoutAlternateSchedulesHandler(IDbContextFactory<TvContext> dbContextFactory) =>
|
||||
_dbContextFactory = dbContextFactory;
|
||||
|
||||
public async Task<List<PlayoutAlternateScheduleViewModel>> Handle(
|
||||
GetPlayoutAlternateSchedules request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
List<PlayoutAlternateScheduleViewModel> result = await dbContext.ProgramScheduleAlternates
|
||||
.Filter(psa => psa.PlayoutId == request.PlayoutId)
|
||||
.Include(psa => psa.ProgramSchedule)
|
||||
.Map(psa => ProjectToViewModel(psa))
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
Option<ProgramSchedule> maybeDefaultSchedule = await dbContext.Playouts
|
||||
.Include(p => p.ProgramSchedule)
|
||||
.SelectOneAsync(p => p.Id, p => p.Id == request.PlayoutId)
|
||||
.MapT(p => p.ProgramSchedule);
|
||||
|
||||
foreach (ProgramSchedule defaultSchedule in maybeDefaultSchedule)
|
||||
{
|
||||
var psa = new ProgramScheduleAlternate
|
||||
{
|
||||
Id = -1,
|
||||
PlayoutId = request.PlayoutId,
|
||||
ProgramScheduleId = defaultSchedule.Id,
|
||||
ProgramSchedule = defaultSchedule,
|
||||
Index = result.Map(i => i.Index).DefaultIfEmpty().Max() + 1,
|
||||
DaysOfMonth = ProgramScheduleAlternate.AllDaysOfMonth(),
|
||||
DaysOfWeek = ProgramScheduleAlternate.AllDaysOfWeek(),
|
||||
MonthsOfYear = ProgramScheduleAlternate.AllMonthsOfYear()
|
||||
};
|
||||
|
||||
result.Add(ProjectToViewModel(psa));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -1,19 +1,26 @@
|
||||
using System.Threading.Channels;
|
||||
using ErsatzTV.Application.Libraries;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.FFmpeg.Runtime;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Extensions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ErsatzTV.Application.Plex;
|
||||
|
||||
public class CallPlexLibraryScannerHandler : CallLibraryScannerHandler,
|
||||
public class CallPlexLibraryScannerHandler : CallLibraryScannerHandler<ISynchronizePlexLibraryById>,
|
||||
IRequestHandler<ForceSynchronizePlexLibraryById, Either<BaseError, string>>,
|
||||
IRequestHandler<SynchronizePlexLibraryByIdIfNeeded, Either<BaseError, string>>
|
||||
{
|
||||
public CallPlexLibraryScannerHandler(
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
IConfigElementRepository configElementRepository,
|
||||
ChannelWriter<ISearchIndexBackgroundServiceRequest> channel,
|
||||
IMediator mediator,
|
||||
IRuntimeInfo runtimeInfo)
|
||||
: base(channel, mediator, runtimeInfo)
|
||||
: base(dbContextFactory, configElementRepository, channel, mediator, runtimeInfo)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -29,10 +36,18 @@ public class CallPlexLibraryScannerHandler : CallLibraryScannerHandler,
|
||||
ISynchronizePlexLibraryById request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Validation<BaseError, string> validation = Validate();
|
||||
Validation<BaseError, string> validation = await Validate(request);
|
||||
return await validation.Match(
|
||||
scanner => PerformScan(scanner, request, cancellationToken),
|
||||
error => Task.FromResult<Either<BaseError, string>>(error.Join()));
|
||||
error =>
|
||||
{
|
||||
foreach (ScanIsNotRequired scanIsNotRequired in error.OfType<ScanIsNotRequired>())
|
||||
{
|
||||
return Task.FromResult<Either<BaseError, string>>(scanIsNotRequired);
|
||||
}
|
||||
|
||||
return Task.FromResult<Either<BaseError, string>>(error.Join());
|
||||
});
|
||||
}
|
||||
|
||||
private async Task<Either<BaseError, string>> PerformScan(
|
||||
@@ -57,4 +72,27 @@ public class CallPlexLibraryScannerHandler : CallLibraryScannerHandler,
|
||||
|
||||
return await base.PerformScan(scanner, arguments, cancellationToken);
|
||||
}
|
||||
|
||||
protected override async Task<DateTimeOffset> GetLastScan(
|
||||
TvContext dbContext,
|
||||
ISynchronizePlexLibraryById request)
|
||||
{
|
||||
return await dbContext.PlexLibraries
|
||||
.SelectOneAsync(l => l.Id, l => l.Id == request.PlexLibraryId)
|
||||
.Match(l => l.LastScan ?? SystemTime.MinValueUtc, () => SystemTime.MaxValueUtc);
|
||||
}
|
||||
|
||||
protected override bool ScanIsRequired(
|
||||
DateTimeOffset lastScan,
|
||||
int libraryRefreshInterval,
|
||||
ISynchronizePlexLibraryById request)
|
||||
{
|
||||
if (lastScan == SystemTime.MaxValueUtc)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
DateTimeOffset nextScan = lastScan + TimeSpan.FromHours(libraryRefreshInterval);
|
||||
return request.ForceScan || (libraryRefreshInterval > 0 && nextScan < DateTimeOffset.Now);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
using System.Diagnostics;
|
||||
using System.Threading.Channels;
|
||||
using ErsatzTV.Application.Maintenance;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Errors;
|
||||
@@ -14,6 +16,7 @@ namespace ErsatzTV.Application.Streaming;
|
||||
public class StartFFmpegSessionHandler : IRequestHandler<StartFFmpegSession, Either<BaseError, Unit>>
|
||||
{
|
||||
private readonly IConfigElementRepository _configElementRepository;
|
||||
private readonly ChannelWriter<IBackgroundServiceRequest> _workerChannel;
|
||||
private readonly IFFmpegSegmenterService _ffmpegSegmenterService;
|
||||
private readonly ILocalFileSystem _localFileSystem;
|
||||
private readonly ILogger<StartFFmpegSessionHandler> _logger;
|
||||
@@ -24,13 +27,15 @@ public class StartFFmpegSessionHandler : IRequestHandler<StartFFmpegSession, Eit
|
||||
ILogger<StartFFmpegSessionHandler> logger,
|
||||
IServiceScopeFactory serviceScopeFactory,
|
||||
IFFmpegSegmenterService ffmpegSegmenterService,
|
||||
IConfigElementRepository configElementRepository)
|
||||
IConfigElementRepository configElementRepository,
|
||||
ChannelWriter<IBackgroundServiceRequest> workerChannel)
|
||||
{
|
||||
_localFileSystem = localFileSystem;
|
||||
_logger = logger;
|
||||
_serviceScopeFactory = serviceScopeFactory;
|
||||
_ffmpegSegmenterService = ffmpegSegmenterService;
|
||||
_configElementRepository = configElementRepository;
|
||||
_workerChannel = workerChannel;
|
||||
}
|
||||
|
||||
public Task<Either<BaseError, Unit>> Handle(StartFFmpegSession request, CancellationToken cancellationToken) =>
|
||||
@@ -54,9 +59,14 @@ public class StartFFmpegSessionHandler : IRequestHandler<StartFFmpegSession, Eit
|
||||
// fire and forget worker
|
||||
_ = worker.Run(request.ChannelNumber, idleTimeout, cancellationToken)
|
||||
.ContinueWith(
|
||||
_ => _ffmpegSegmenterService.SessionWorkers.TryRemove(
|
||||
request.ChannelNumber,
|
||||
out IHlsSessionWorker _),
|
||||
_ =>
|
||||
{
|
||||
_ffmpegSegmenterService.SessionWorkers.TryRemove(
|
||||
request.ChannelNumber,
|
||||
out IHlsSessionWorker _);
|
||||
|
||||
_workerChannel.TryWrite(new ReleaseMemory(false));
|
||||
},
|
||||
TaskScheduler.Default);
|
||||
|
||||
string playlistFileName = Path.Combine(
|
||||
|
||||
@@ -3,4 +3,4 @@
|
||||
namespace ErsatzTV.Application.Subtitles;
|
||||
|
||||
public record ExtractEmbeddedSubtitles(Option<int> PlayoutId) : IRequest<Either<BaseError, Unit>>,
|
||||
ISubtitleWorkerRequest;
|
||||
IBackgroundServiceRequest;
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Threading.Channels;
|
||||
using CliWrap;
|
||||
using CliWrap.Buffered;
|
||||
using CliWrap.Builders;
|
||||
using ErsatzTV.Application.Maintenance;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Extensions;
|
||||
@@ -21,6 +23,7 @@ public class ExtractEmbeddedSubtitlesHandler : IRequestHandler<ExtractEmbeddedSu
|
||||
{
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||
private readonly IEmbyPathReplacementService _embyPathReplacementService;
|
||||
private readonly ChannelWriter<IBackgroundServiceRequest> _workerChannel;
|
||||
private readonly IJellyfinPathReplacementService _jellyfinPathReplacementService;
|
||||
private readonly ILocalFileSystem _localFileSystem;
|
||||
private readonly ILogger<ExtractEmbeddedSubtitlesHandler> _logger;
|
||||
@@ -32,6 +35,7 @@ public class ExtractEmbeddedSubtitlesHandler : IRequestHandler<ExtractEmbeddedSu
|
||||
IPlexPathReplacementService plexPathReplacementService,
|
||||
IJellyfinPathReplacementService jellyfinPathReplacementService,
|
||||
IEmbyPathReplacementService embyPathReplacementService,
|
||||
ChannelWriter<IBackgroundServiceRequest> workerChannel,
|
||||
ILogger<ExtractEmbeddedSubtitlesHandler> logger)
|
||||
{
|
||||
_dbContextFactory = dbContextFactory;
|
||||
@@ -39,6 +43,7 @@ public class ExtractEmbeddedSubtitlesHandler : IRequestHandler<ExtractEmbeddedSu
|
||||
_plexPathReplacementService = plexPathReplacementService;
|
||||
_jellyfinPathReplacementService = jellyfinPathReplacementService;
|
||||
_embyPathReplacementService = embyPathReplacementService;
|
||||
_workerChannel = workerChannel;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
@@ -49,7 +54,12 @@ public class ExtractEmbeddedSubtitlesHandler : IRequestHandler<ExtractEmbeddedSu
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
Validation<BaseError, string> validation = await FFmpegPathMustExist(dbContext);
|
||||
return await validation.Match(
|
||||
ffmpegPath => ExtractAll(dbContext, request, ffmpegPath, cancellationToken),
|
||||
async ffmpegPath =>
|
||||
{
|
||||
Either<BaseError, Unit> result = await ExtractAll(dbContext, request, ffmpegPath, cancellationToken);
|
||||
await _workerChannel.WriteAsync(new ReleaseMemory(false), cancellationToken);
|
||||
return result;
|
||||
},
|
||||
error => Task.FromResult<Either<BaseError, Unit>>(error.Join()));
|
||||
}
|
||||
|
||||
@@ -68,6 +78,7 @@ public class ExtractEmbeddedSubtitlesHandler : IRequestHandler<ExtractEmbeddedSu
|
||||
|
||||
// only check the requested playout if subtitles are enabled
|
||||
Option<Playout> requestedPlayout = await dbContext.Playouts
|
||||
.AsNoTracking()
|
||||
.Filter(
|
||||
p => p.Channel.SubtitleMode != ChannelSubtitleMode.None ||
|
||||
p.ProgramSchedule.Items.Any(psi => psi.SubtitleMode != ChannelSubtitleMode.None))
|
||||
@@ -79,6 +90,7 @@ public class ExtractEmbeddedSubtitlesHandler : IRequestHandler<ExtractEmbeddedSu
|
||||
if (request.PlayoutId.IsNone)
|
||||
{
|
||||
playoutIdsToCheck = dbContext.Playouts
|
||||
.AsNoTracking()
|
||||
.Filter(
|
||||
p => p.Channel.SubtitleMode != ChannelSubtitleMode.None ||
|
||||
p.ProgramSchedule.Items.Any(psi => psi.SubtitleMode != ChannelSubtitleMode.None))
|
||||
@@ -104,6 +116,7 @@ public class ExtractEmbeddedSubtitlesHandler : IRequestHandler<ExtractEmbeddedSu
|
||||
|
||||
// find all playout items in the next hour
|
||||
List<PlayoutItem> playoutItems = await dbContext.PlayoutItems
|
||||
.AsNoTracking()
|
||||
.Filter(pi => playoutIdsToCheck.Contains(pi.PlayoutId))
|
||||
.Filter(pi => pi.Finish >= DateTime.UtcNow)
|
||||
.Filter(pi => pi.Start <= until)
|
||||
@@ -170,6 +183,7 @@ public class ExtractEmbeddedSubtitlesHandler : IRequestHandler<ExtractEmbeddedSu
|
||||
try
|
||||
{
|
||||
List<int> episodeIds = await dbContext.EpisodeMetadata
|
||||
.AsNoTracking()
|
||||
.Filter(em => mediaItemIds.Contains(em.EpisodeId))
|
||||
.Filter(
|
||||
em => em.Subtitles.Any(
|
||||
@@ -180,6 +194,7 @@ public class ExtractEmbeddedSubtitlesHandler : IRequestHandler<ExtractEmbeddedSu
|
||||
result.AddRange(episodeIds);
|
||||
|
||||
List<int> movieIds = await dbContext.MovieMetadata
|
||||
.AsNoTracking()
|
||||
.Filter(mm => mediaItemIds.Contains(mm.MovieId))
|
||||
.Filter(
|
||||
mm => mm.Subtitles.Any(
|
||||
@@ -190,6 +205,7 @@ public class ExtractEmbeddedSubtitlesHandler : IRequestHandler<ExtractEmbeddedSu
|
||||
result.AddRange(movieIds);
|
||||
|
||||
List<int> musicVideoIds = await dbContext.MusicVideoMetadata
|
||||
.AsNoTracking()
|
||||
.Filter(mm => mediaItemIds.Contains(mm.MusicVideoId))
|
||||
.Filter(
|
||||
mm => mm.Subtitles.Any(
|
||||
@@ -200,6 +216,7 @@ public class ExtractEmbeddedSubtitlesHandler : IRequestHandler<ExtractEmbeddedSu
|
||||
result.AddRange(musicVideoIds);
|
||||
|
||||
List<int> otherVideoIds = await dbContext.OtherVideoMetadata
|
||||
.AsNoTracking()
|
||||
.Filter(ovm => mediaItemIds.Contains(ovm.OtherVideoId))
|
||||
.Filter(
|
||||
ovm => ovm.Subtitles.Any(
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Bugsnag" Version="3.1.0" />
|
||||
<PackageReference Include="CliWrap" Version="3.6.0" />
|
||||
<PackageReference Include="FluentAssertions" Version="6.8.0" />
|
||||
<PackageReference Include="FluentAssertions" Version="6.9.0" />
|
||||
<PackageReference Include="LanguageExt.Core" Version="4.4.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="7.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="7.0.0" />
|
||||
|
||||
@@ -15,7 +15,7 @@ public class FakeMediaCollectionRepository : IMediaCollectionRepository
|
||||
|
||||
public Task<List<MediaItem>> GetItems(int id) => _data[id].ToList().AsTask();
|
||||
public Task<List<MediaItem>> GetMultiCollectionItems(int id) => throw new NotSupportedException();
|
||||
public Task<List<MediaItem>> GetSmartCollectionItems(int id) => throw new NotSupportedException();
|
||||
public Task<List<MediaItem>> GetSmartCollectionItems(int id) => _data[id].ToList().AsTask();
|
||||
|
||||
public Task<List<CollectionWithItems>> GetMultiCollectionCollections(int id) =>
|
||||
throw new NotSupportedException();
|
||||
|
||||
@@ -543,7 +543,8 @@ public class PlayoutBuilderTests
|
||||
},
|
||||
Channel = new Channel(Guid.Empty) { Id = 1, Name = "Test Channel" },
|
||||
ProgramScheduleAnchors = new List<PlayoutProgramScheduleAnchor>(),
|
||||
Items = new List<PlayoutItem>()
|
||||
Items = new List<PlayoutItem>(),
|
||||
ProgramScheduleAlternates = new List<ProgramScheduleAlternate>()
|
||||
};
|
||||
|
||||
var configRepo = new Mock<IConfigElementRepository>();
|
||||
@@ -639,7 +640,8 @@ public class PlayoutBuilderTests
|
||||
},
|
||||
Channel = new Channel(Guid.Empty) { Id = 1, Name = "Test Channel" },
|
||||
ProgramScheduleAnchors = new List<PlayoutProgramScheduleAnchor>(),
|
||||
Items = new List<PlayoutItem>()
|
||||
Items = new List<PlayoutItem>(),
|
||||
ProgramScheduleAlternates = new List<ProgramScheduleAlternate>()
|
||||
};
|
||||
|
||||
var configRepo = new Mock<IConfigElementRepository>();
|
||||
@@ -783,7 +785,8 @@ public class PlayoutBuilderTests
|
||||
},
|
||||
Channel = new Channel(Guid.Empty) { Id = 1, Name = "Test Channel" },
|
||||
ProgramScheduleAnchors = new List<PlayoutProgramScheduleAnchor>(),
|
||||
Items = new List<PlayoutItem>()
|
||||
Items = new List<PlayoutItem>(),
|
||||
ProgramScheduleAlternates = new List<ProgramScheduleAlternate>()
|
||||
};
|
||||
|
||||
var configRepo = new Mock<IConfigElementRepository>();
|
||||
@@ -885,7 +888,8 @@ public class PlayoutBuilderTests
|
||||
},
|
||||
Channel = new Channel(Guid.Empty) { Id = 1, Name = "Test Channel" },
|
||||
ProgramScheduleAnchors = new List<PlayoutProgramScheduleAnchor>(),
|
||||
Items = new List<PlayoutItem>()
|
||||
Items = new List<PlayoutItem>(),
|
||||
ProgramScheduleAlternates = new List<ProgramScheduleAlternate>()
|
||||
};
|
||||
|
||||
var configRepo = new Mock<IConfigElementRepository>();
|
||||
@@ -996,7 +1000,8 @@ public class PlayoutBuilderTests
|
||||
InFlood = true
|
||||
},
|
||||
ProgramScheduleAnchors = new List<PlayoutProgramScheduleAnchor>(),
|
||||
Items = new List<PlayoutItem>()
|
||||
Items = new List<PlayoutItem>(),
|
||||
ProgramScheduleAlternates = new List<ProgramScheduleAlternate>()
|
||||
};
|
||||
|
||||
var configRepo = new Mock<IConfigElementRepository>();
|
||||
@@ -1100,7 +1105,8 @@ public class PlayoutBuilderTests
|
||||
},
|
||||
Channel = new Channel(Guid.Empty) { Id = 1, Name = "Test Channel" },
|
||||
ProgramScheduleAnchors = new List<PlayoutProgramScheduleAnchor>(),
|
||||
Items = new List<PlayoutItem>()
|
||||
Items = new List<PlayoutItem>(),
|
||||
ProgramScheduleAlternates = new List<ProgramScheduleAlternate>()
|
||||
};
|
||||
|
||||
var configRepo = new Mock<IConfigElementRepository>();
|
||||
@@ -1208,7 +1214,8 @@ public class PlayoutBuilderTests
|
||||
},
|
||||
Channel = new Channel(Guid.Empty) { Id = 1, Name = "Test Channel" },
|
||||
ProgramScheduleAnchors = new List<PlayoutProgramScheduleAnchor>(),
|
||||
Items = new List<PlayoutItem>()
|
||||
Items = new List<PlayoutItem>(),
|
||||
ProgramScheduleAlternates = new List<ProgramScheduleAlternate>()
|
||||
};
|
||||
|
||||
var configRepo = new Mock<IConfigElementRepository>();
|
||||
@@ -1321,7 +1328,8 @@ public class PlayoutBuilderTests
|
||||
MultipleRemaining = 2
|
||||
},
|
||||
ProgramScheduleAnchors = new List<PlayoutProgramScheduleAnchor>(),
|
||||
Items = new List<PlayoutItem>()
|
||||
Items = new List<PlayoutItem>(),
|
||||
ProgramScheduleAlternates = new List<ProgramScheduleAlternate>()
|
||||
};
|
||||
|
||||
var configRepo = new Mock<IConfigElementRepository>();
|
||||
@@ -1423,7 +1431,8 @@ public class PlayoutBuilderTests
|
||||
},
|
||||
Channel = new Channel(Guid.Empty) { Id = 1, Name = "Test Channel" },
|
||||
ProgramScheduleAnchors = new List<PlayoutProgramScheduleAnchor>(),
|
||||
Items = new List<PlayoutItem>()
|
||||
Items = new List<PlayoutItem>(),
|
||||
ProgramScheduleAlternates = new List<ProgramScheduleAlternate>()
|
||||
};
|
||||
|
||||
var configRepo = new Mock<IConfigElementRepository>();
|
||||
@@ -1536,7 +1545,8 @@ public class PlayoutBuilderTests
|
||||
DurationFinish = HoursAfterMidnight(3).UtcDateTime
|
||||
},
|
||||
ProgramScheduleAnchors = new List<PlayoutProgramScheduleAnchor>(),
|
||||
Items = new List<PlayoutItem>()
|
||||
Items = new List<PlayoutItem>(),
|
||||
ProgramScheduleAlternates = new List<ProgramScheduleAlternate>()
|
||||
};
|
||||
|
||||
var configRepo = new Mock<IConfigElementRepository>();
|
||||
@@ -1660,7 +1670,8 @@ public class PlayoutBuilderTests
|
||||
},
|
||||
Channel = new Channel(Guid.Empty) { Id = 1, Name = "Test Channel" },
|
||||
ProgramScheduleAnchors = new List<PlayoutProgramScheduleAnchor>(),
|
||||
Items = new List<PlayoutItem>()
|
||||
Items = new List<PlayoutItem>(),
|
||||
ProgramScheduleAlternates = new List<ProgramScheduleAlternate>()
|
||||
};
|
||||
|
||||
var configRepo = new Mock<IConfigElementRepository>();
|
||||
@@ -1776,7 +1787,8 @@ public class PlayoutBuilderTests
|
||||
},
|
||||
Channel = new Channel(Guid.Empty) { Id = 1, Name = "Test Channel" },
|
||||
ProgramScheduleAnchors = new List<PlayoutProgramScheduleAnchor>(),
|
||||
Items = new List<PlayoutItem>()
|
||||
Items = new List<PlayoutItem>(),
|
||||
ProgramScheduleAlternates = new List<ProgramScheduleAlternate>()
|
||||
};
|
||||
|
||||
var configRepo = new Mock<IConfigElementRepository>();
|
||||
@@ -1852,7 +1864,8 @@ public class PlayoutBuilderTests
|
||||
},
|
||||
Channel = new Channel(Guid.Empty) { Id = 1, Name = "Test Channel" },
|
||||
ProgramScheduleAnchors = new List<PlayoutProgramScheduleAnchor>(),
|
||||
Items = new List<PlayoutItem>()
|
||||
Items = new List<PlayoutItem>(),
|
||||
ProgramScheduleAlternates = new List<ProgramScheduleAlternate>()
|
||||
};
|
||||
|
||||
var configRepo = new Mock<IConfigElementRepository>();
|
||||
@@ -2049,7 +2062,8 @@ public class PlayoutBuilderTests
|
||||
},
|
||||
|
||||
ProgramScheduleAnchors = new List<PlayoutProgramScheduleAnchor>(),
|
||||
Items = new List<PlayoutItem>()
|
||||
Items = new List<PlayoutItem>(),
|
||||
ProgramScheduleAlternates = new List<ProgramScheduleAlternate>()
|
||||
};
|
||||
|
||||
playout.ProgramScheduleAnchors.Add(
|
||||
@@ -2366,6 +2380,91 @@ public class PlayoutBuilderTests
|
||||
// the continue anchor should have the same seed as the most recent (last) checkpoint from the first run
|
||||
firstSeedValue.Should().Be(secondSeedValue);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ShuffleFlood_MultipleSmartCollections_Should_MaintainRandomSeed()
|
||||
{
|
||||
var mediaItems = new List<MediaItem>
|
||||
{
|
||||
TestMovie(1, TimeSpan.FromHours(1), DateTime.Today),
|
||||
TestMovie(2, TimeSpan.FromHours(1), DateTime.Today.AddHours(1)),
|
||||
TestMovie(3, TimeSpan.FromHours(1), DateTime.Today.AddHours(3))
|
||||
};
|
||||
|
||||
(PlayoutBuilder builder, Playout playout) =
|
||||
TestDataFloodForSmartCollectionItems(mediaItems, PlaybackOrder.Shuffle);
|
||||
DateTimeOffset start = HoursAfterMidnight(0);
|
||||
DateTimeOffset finish = start + TimeSpan.FromHours(6);
|
||||
|
||||
Playout result = await builder.Build(playout, PlayoutBuildMode.Reset, start, finish);
|
||||
|
||||
result.Items.Count.Should().Be(6);
|
||||
result.ProgramScheduleAnchors.Count.Should().Be(2);
|
||||
PlayoutProgramScheduleAnchor primaryAnchor = result.ProgramScheduleAnchors.First(a => a.SmartCollectionId == 1);
|
||||
primaryAnchor.EnumeratorState.Seed.Should().BeGreaterThan(0);
|
||||
primaryAnchor.EnumeratorState.Index.Should().Be(0);
|
||||
|
||||
int firstSeedValue = primaryAnchor.EnumeratorState.Seed;
|
||||
|
||||
DateTimeOffset start2 = HoursAfterMidnight(0);
|
||||
DateTimeOffset finish2 = start2 + TimeSpan.FromHours(6);
|
||||
|
||||
Playout result2 = await builder.Build(result, PlayoutBuildMode.Continue, start2, finish2);
|
||||
|
||||
primaryAnchor = result2.ProgramScheduleAnchors.First(a => a.SmartCollectionId == 1);
|
||||
int secondSeedValue = primaryAnchor.EnumeratorState.Seed;
|
||||
|
||||
firstSeedValue.Should().Be(secondSeedValue);
|
||||
|
||||
primaryAnchor.EnumeratorState.Index.Should().Be(0);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ShuffleFlood_MultipleSmartCollections_Should_MaintainRandomSeed_MultipleDays()
|
||||
{
|
||||
var mediaItems = new List<MediaItem>();
|
||||
for (int i = 1; i <= 100; i++)
|
||||
{
|
||||
mediaItems.Add(TestMovie(i, TimeSpan.FromMinutes(55), DateTime.Today.AddHours(i)));
|
||||
}
|
||||
|
||||
(PlayoutBuilder builder, Playout playout) =
|
||||
TestDataFloodForSmartCollectionItems(mediaItems, PlaybackOrder.Shuffle);
|
||||
DateTimeOffset start = HoursAfterMidnight(0).AddSeconds(5);
|
||||
DateTimeOffset finish = start + TimeSpan.FromDays(2);
|
||||
|
||||
Playout result = await builder.Build(playout, PlayoutBuildMode.Reset, start, finish);
|
||||
|
||||
result.Items.Count.Should().Be(53);
|
||||
result.ProgramScheduleAnchors.Count.Should().Be(4);
|
||||
|
||||
result.ProgramScheduleAnchors.All(x => x.AnchorDate is not null).Should().BeTrue();
|
||||
PlayoutProgramScheduleAnchor lastCheckpoint = result.ProgramScheduleAnchors
|
||||
.Filter(psa => psa.SmartCollectionId == 1)
|
||||
.OrderByDescending(a => a.AnchorDate ?? DateTime.MinValue)
|
||||
.First();
|
||||
lastCheckpoint.EnumeratorState.Seed.Should().BeGreaterThan(0);
|
||||
lastCheckpoint.EnumeratorState.Index.Should().Be(53);
|
||||
|
||||
int firstSeedValue = lastCheckpoint.EnumeratorState.Seed;
|
||||
|
||||
for (var i = 1; i < 20; i++)
|
||||
{
|
||||
DateTimeOffset start2 = start.AddHours(i);
|
||||
DateTimeOffset finish2 = start2 + TimeSpan.FromDays(2);
|
||||
|
||||
Playout result2 = await builder.Build(result, PlayoutBuildMode.Continue, start2, finish2);
|
||||
|
||||
PlayoutProgramScheduleAnchor continueAnchor =
|
||||
result2.ProgramScheduleAnchors
|
||||
.Filter(psa => psa.SmartCollectionId == 1)
|
||||
.First(x => x.AnchorDate is null);
|
||||
int secondSeedValue = continueAnchor.EnumeratorState.Seed;
|
||||
|
||||
// the continue anchor should have the same seed as the most recent (last) checkpoint from the first run
|
||||
firstSeedValue.Should().Be(secondSeedValue);
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task FloodContent_Should_FloodWithFixedStartTime_FromAnchor()
|
||||
@@ -2437,7 +2536,8 @@ public class PlayoutBuilderTests
|
||||
InFlood = true
|
||||
},
|
||||
ProgramScheduleAnchors = new List<PlayoutProgramScheduleAnchor>(),
|
||||
Items = new List<PlayoutItem>()
|
||||
Items = new List<PlayoutItem>(),
|
||||
ProgramScheduleAlternates = new List<ProgramScheduleAlternate>()
|
||||
};
|
||||
|
||||
var configRepo = new Mock<IConfigElementRepository>();
|
||||
@@ -2548,7 +2648,8 @@ public class PlayoutBuilderTests
|
||||
MultipleRemaining = 2
|
||||
},
|
||||
ProgramScheduleAnchors = new List<PlayoutProgramScheduleAnchor>(),
|
||||
Items = new List<PlayoutItem>()
|
||||
Items = new List<PlayoutItem>(),
|
||||
ProgramScheduleAlternates = new List<ProgramScheduleAlternate>()
|
||||
};
|
||||
|
||||
var configRepo = new Mock<IConfigElementRepository>();
|
||||
@@ -2659,7 +2760,8 @@ public class PlayoutBuilderTests
|
||||
DurationFinish = HoursAfterMidnight(3).UtcDateTime
|
||||
},
|
||||
ProgramScheduleAnchors = new List<PlayoutProgramScheduleAnchor>(),
|
||||
Items = new List<PlayoutItem>()
|
||||
Items = new List<PlayoutItem>(),
|
||||
ProgramScheduleAlternates = new List<ProgramScheduleAlternate>()
|
||||
};
|
||||
|
||||
var configRepo = new Mock<IConfigElementRepository>();
|
||||
@@ -2710,10 +2812,34 @@ public class PlayoutBuilderTests
|
||||
{
|
||||
Id = 1,
|
||||
Index = 1,
|
||||
CollectionType = ProgramScheduleItemCollectionType.Collection,
|
||||
Collection = mediaCollection,
|
||||
CollectionId = mediaCollection.Id,
|
||||
StartTime = null,
|
||||
PlaybackOrder = playbackOrder
|
||||
PlaybackOrder = playbackOrder,
|
||||
};
|
||||
|
||||
private static ProgramScheduleItem Flood(
|
||||
SmartCollection smartCollection,
|
||||
SmartCollection fillerCollection,
|
||||
PlaybackOrder playbackOrder) =>
|
||||
new ProgramScheduleItemFlood
|
||||
{
|
||||
Id = 1,
|
||||
Index = 1,
|
||||
CollectionType = ProgramScheduleItemCollectionType.SmartCollection,
|
||||
SmartCollection = smartCollection,
|
||||
SmartCollectionId = smartCollection.Id,
|
||||
StartTime = null,
|
||||
PlaybackOrder = playbackOrder,
|
||||
FallbackFiller = new FillerPreset
|
||||
{
|
||||
Id = 1,
|
||||
CollectionType = ProgramScheduleItemCollectionType.SmartCollection,
|
||||
SmartCollection = fillerCollection,
|
||||
SmartCollectionId = fillerCollection.Id,
|
||||
FillerKind = FillerKind.Fallback
|
||||
}
|
||||
};
|
||||
|
||||
private static Movie TestMovie(int id, TimeSpan duration, DateTime aired) =>
|
||||
@@ -2768,7 +2894,61 @@ public class PlayoutBuilderTests
|
||||
ProgramSchedule = new ProgramSchedule { Items = items },
|
||||
Channel = new Channel(Guid.Empty) { Id = 1, Name = "Test Channel" },
|
||||
Items = new List<PlayoutItem>(),
|
||||
ProgramScheduleAnchors = new List<PlayoutProgramScheduleAnchor>()
|
||||
ProgramScheduleAnchors = new List<PlayoutProgramScheduleAnchor>(),
|
||||
ProgramScheduleAlternates = new List<ProgramScheduleAlternate>()
|
||||
};
|
||||
|
||||
return new TestData(builder, playout);
|
||||
}
|
||||
|
||||
private TestData TestDataFloodForSmartCollectionItems(
|
||||
List<MediaItem> mediaItems,
|
||||
PlaybackOrder playbackOrder,
|
||||
Mock<IConfigElementRepository> configMock = null)
|
||||
{
|
||||
var mediaCollection = new SmartCollection
|
||||
{
|
||||
Id = 1,
|
||||
Query = "asdf"
|
||||
};
|
||||
|
||||
var fillerCollection = new SmartCollection
|
||||
{
|
||||
Id = 2,
|
||||
Query = "ghjk"
|
||||
};
|
||||
|
||||
Mock<IConfigElementRepository> configRepo = configMock ?? new Mock<IConfigElementRepository>();
|
||||
|
||||
var collectionRepo = new FakeMediaCollectionRepository(
|
||||
Map(
|
||||
(mediaCollection.Id, mediaItems),
|
||||
(fillerCollection.Id, mediaItems.Take(1).ToList())
|
||||
)
|
||||
);
|
||||
var televisionRepo = new FakeTelevisionRepository();
|
||||
var artistRepo = new Mock<IArtistRepository>();
|
||||
var factory = new Mock<IMultiEpisodeShuffleCollectionEnumeratorFactory>();
|
||||
var localFileSystem = new Mock<ILocalFileSystem>();
|
||||
var builder = new PlayoutBuilder(
|
||||
configRepo.Object,
|
||||
collectionRepo,
|
||||
televisionRepo,
|
||||
artistRepo.Object,
|
||||
factory.Object,
|
||||
localFileSystem.Object,
|
||||
_logger);
|
||||
|
||||
var items = new List<ProgramScheduleItem> { Flood(mediaCollection, fillerCollection, playbackOrder) };
|
||||
|
||||
var playout = new Playout
|
||||
{
|
||||
Id = 1,
|
||||
ProgramSchedule = new ProgramSchedule { Items = items },
|
||||
Channel = new Channel(Guid.Empty) { Id = 1, Name = "Test Channel" },
|
||||
Items = new List<PlayoutItem>(),
|
||||
ProgramScheduleAnchors = new List<PlayoutProgramScheduleAnchor>(),
|
||||
ProgramScheduleAlternates = new List<ProgramScheduleAlternate>()
|
||||
};
|
||||
|
||||
return new TestData(builder, playout);
|
||||
|
||||
@@ -3,12 +3,17 @@ using Dapper;
|
||||
using Destructurama;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Interfaces.Repositories.Caching;
|
||||
using ErsatzTV.Core.Interfaces.Scheduling;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using ErsatzTV.Core.Metadata;
|
||||
using ErsatzTV.Core.Scheduling;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Data.Repositories;
|
||||
using ErsatzTV.Infrastructure.Data.Repositories.Caching;
|
||||
using ErsatzTV.Infrastructure.Extensions;
|
||||
using ErsatzTV.Infrastructure.Search;
|
||||
using LanguageExt.UnsafeValueAccess;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
@@ -28,7 +33,7 @@ public class ScheduleIntegrationTests
|
||||
public ScheduleIntegrationTests()
|
||||
{
|
||||
Log.Logger = new LoggerConfiguration()
|
||||
.MinimumLevel.Information()
|
||||
.MinimumLevel.Debug()
|
||||
.MinimumLevel.Override("Microsoft", LogEventLevel.Warning)
|
||||
.MinimumLevel.Override("System.Net.Http.HttpClient", LogEventLevel.Warning)
|
||||
.WriteTo.Console()
|
||||
@@ -37,7 +42,123 @@ public class ScheduleIntegrationTests
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Test()
|
||||
public async Task TestExistingData()
|
||||
{
|
||||
const string DB_FILE_NAME = "/tmp/whatever.sqlite3";
|
||||
const int PLAYOUT_ID = 39;
|
||||
|
||||
var start = new DateTimeOffset(2023, 1, 18, 11, 0, 0, TimeSpan.FromHours(-5));
|
||||
DateTimeOffset finish = start.AddDays(2);
|
||||
|
||||
IServiceCollection services = new ServiceCollection()
|
||||
.AddLogging();
|
||||
|
||||
var connectionString = $"Data Source={DB_FILE_NAME};foreign keys=true;";
|
||||
|
||||
services.AddDbContext<TvContext>(
|
||||
options => options.UseSqlite(
|
||||
connectionString,
|
||||
o =>
|
||||
{
|
||||
o.UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery);
|
||||
o.MigrationsAssembly("ErsatzTV.Infrastructure");
|
||||
}),
|
||||
ServiceLifetime.Scoped,
|
||||
ServiceLifetime.Singleton);
|
||||
|
||||
services.AddDbContextFactory<TvContext>(
|
||||
options => options.UseSqlite(
|
||||
connectionString,
|
||||
o =>
|
||||
{
|
||||
o.UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery);
|
||||
o.MigrationsAssembly("ErsatzTV.Infrastructure");
|
||||
}));
|
||||
|
||||
SqlMapper.AddTypeHandler(new DateTimeOffsetHandler());
|
||||
SqlMapper.AddTypeHandler(new GuidHandler());
|
||||
SqlMapper.AddTypeHandler(new TimeSpanHandler());
|
||||
|
||||
services.AddSingleton((Func<IServiceProvider, ILoggerFactory>)(_ => new SerilogLoggerFactory()));
|
||||
|
||||
services.AddScoped<ISearchRepository, SearchRepository>();
|
||||
services.AddScoped<ICachingSearchRepository, CachingSearchRepository>();
|
||||
services.AddScoped<IConfigElementRepository, ConfigElementRepository>();
|
||||
services.AddScoped<IFallbackMetadataProvider, FallbackMetadataProvider>();
|
||||
|
||||
services.AddSingleton<ISearchIndex, SearchIndex>();
|
||||
|
||||
services.AddSingleton(_ => new Mock<IClient>().Object);
|
||||
|
||||
ServiceProvider provider = services.BuildServiceProvider();
|
||||
|
||||
IDbContextFactory<TvContext> factory = provider.GetRequiredService<IDbContextFactory<TvContext>>();
|
||||
|
||||
ILogger<ScheduleIntegrationTests> logger = provider.GetRequiredService<ILogger<ScheduleIntegrationTests>>();
|
||||
logger.LogInformation("Database is at {File}", DB_FILE_NAME);
|
||||
|
||||
await using TvContext dbContext = await factory.CreateDbContextAsync(CancellationToken.None);
|
||||
await dbContext.Database.MigrateAsync(CancellationToken.None);
|
||||
await DbInitializer.Initialize(dbContext, CancellationToken.None);
|
||||
|
||||
ISearchIndex searchIndex = provider.GetRequiredService<ISearchIndex>();
|
||||
await searchIndex.Initialize(
|
||||
new LocalFileSystem(
|
||||
provider.GetRequiredService<IClient>(),
|
||||
provider.GetRequiredService<ILogger<LocalFileSystem>>()),
|
||||
provider.GetRequiredService<IConfigElementRepository>());
|
||||
|
||||
await searchIndex.Rebuild(
|
||||
provider.GetRequiredService<ICachingSearchRepository>(),
|
||||
provider.GetRequiredService<IFallbackMetadataProvider>());
|
||||
|
||||
var builder = new PlayoutBuilder(
|
||||
new ConfigElementRepository(factory),
|
||||
new MediaCollectionRepository(new Mock<IClient>().Object, searchIndex, factory),
|
||||
new TelevisionRepository(factory),
|
||||
new ArtistRepository(factory),
|
||||
new Mock<IMultiEpisodeShuffleCollectionEnumeratorFactory>().Object,
|
||||
new Mock<ILocalFileSystem>().Object,
|
||||
provider.GetRequiredService<ILogger<PlayoutBuilder>>());
|
||||
|
||||
{
|
||||
await using TvContext context = await factory.CreateDbContextAsync();
|
||||
|
||||
Option<Playout> maybePlayout = await GetPlayout(context, PLAYOUT_ID);
|
||||
Playout playout = maybePlayout.ValueUnsafe();
|
||||
|
||||
await builder.Build(playout, PlayoutBuildMode.Reset, start, finish);
|
||||
|
||||
await context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
for (var i = 1; i <= (24 * 1); i++)
|
||||
{
|
||||
await using TvContext context = await factory.CreateDbContextAsync();
|
||||
|
||||
Option<Playout> maybePlayout = await GetPlayout(context, PLAYOUT_ID);
|
||||
Playout playout = maybePlayout.ValueUnsafe();
|
||||
|
||||
await builder.Build(playout, PlayoutBuildMode.Continue, start.AddHours(i), finish.AddHours(i));
|
||||
|
||||
await context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
for (var i = 25; i <= 26; i++)
|
||||
{
|
||||
await using TvContext context = await factory.CreateDbContextAsync();
|
||||
|
||||
Option<Playout> maybePlayout = await GetPlayout(context, PLAYOUT_ID);
|
||||
Playout playout = maybePlayout.ValueUnsafe();
|
||||
|
||||
await builder.Build(playout, PlayoutBuildMode.Continue, start.AddHours(i), finish.AddHours(i));
|
||||
|
||||
await context.SaveChangesAsync();
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task TestMockData()
|
||||
{
|
||||
string dbFileName = Path.GetTempFileName() + ".sqlite3";
|
||||
|
||||
@@ -221,8 +342,41 @@ public class ScheduleIntegrationTests
|
||||
return await dbContext.Playouts
|
||||
.Include(p => p.Channel)
|
||||
.Include(p => p.Items)
|
||||
|
||||
.Include(p => p.ProgramScheduleAlternates)
|
||||
.ThenInclude(a => a.ProgramSchedule)
|
||||
.ThenInclude(ps => ps.Items)
|
||||
.ThenInclude(psi => psi.Collection)
|
||||
.Include(p => p.ProgramScheduleAlternates)
|
||||
.ThenInclude(a => a.ProgramSchedule)
|
||||
.ThenInclude(ps => ps.Items)
|
||||
.ThenInclude(psi => psi.MediaItem)
|
||||
.Include(p => p.ProgramScheduleAlternates)
|
||||
.ThenInclude(a => a.ProgramSchedule)
|
||||
.ThenInclude(ps => ps.Items)
|
||||
.ThenInclude(psi => psi.PreRollFiller)
|
||||
.Include(p => p.ProgramScheduleAlternates)
|
||||
.ThenInclude(a => a.ProgramSchedule)
|
||||
.ThenInclude(ps => ps.Items)
|
||||
.ThenInclude(psi => psi.MidRollFiller)
|
||||
.Include(p => p.ProgramScheduleAlternates)
|
||||
.ThenInclude(a => a.ProgramSchedule)
|
||||
.ThenInclude(ps => ps.Items)
|
||||
.ThenInclude(psi => psi.PostRollFiller)
|
||||
.Include(p => p.ProgramScheduleAlternates)
|
||||
.ThenInclude(a => a.ProgramSchedule)
|
||||
.ThenInclude(ps => ps.Items)
|
||||
.ThenInclude(psi => psi.TailFiller)
|
||||
.Include(p => p.ProgramScheduleAlternates)
|
||||
.ThenInclude(a => a.ProgramSchedule)
|
||||
.ThenInclude(ps => ps.Items)
|
||||
.ThenInclude(psi => psi.FallbackFiller)
|
||||
|
||||
.Include(p => p.ProgramScheduleAnchors)
|
||||
.ThenInclude(a => a.EnumeratorState)
|
||||
.Include(p => p.ProgramScheduleAnchors)
|
||||
.ThenInclude(a => a.MediaItem)
|
||||
|
||||
.Include(p => p.ProgramSchedule)
|
||||
.ThenInclude(ps => ps.Items)
|
||||
.ThenInclude(psi => psi.Collection)
|
||||
|
||||
@@ -7,6 +7,7 @@ public class Playout
|
||||
public Channel Channel { get; set; }
|
||||
public int ProgramScheduleId { get; set; }
|
||||
public ProgramSchedule ProgramSchedule { get; set; }
|
||||
public List<ProgramScheduleAlternate> ProgramScheduleAlternates { get; set; }
|
||||
public ProgramSchedulePlayoutType ProgramSchedulePlayoutType { get; set; }
|
||||
public List<PlayoutItem> Items { get; set; }
|
||||
public PlayoutAnchor Anchor { get; set; }
|
||||
|
||||
@@ -10,4 +10,5 @@ public class ProgramSchedule
|
||||
public bool RandomStartPoint { get; set; }
|
||||
public List<ProgramScheduleItem> Items { get; set; }
|
||||
public List<Playout> Playouts { get; set; }
|
||||
public List<ProgramScheduleAlternate> ProgramScheduleAlternates { get; set; }
|
||||
}
|
||||
|
||||
28
ErsatzTV.Core/Domain/ProgramScheduleAlternate.cs
Normal file
28
ErsatzTV.Core/Domain/ProgramScheduleAlternate.cs
Normal file
@@ -0,0 +1,28 @@
|
||||
namespace ErsatzTV.Core.Domain;
|
||||
|
||||
public class ProgramScheduleAlternate
|
||||
{
|
||||
public static List<DayOfWeek> AllDaysOfWeek() => new()
|
||||
{
|
||||
DayOfWeek.Monday,
|
||||
DayOfWeek.Tuesday,
|
||||
DayOfWeek.Wednesday,
|
||||
DayOfWeek.Thursday,
|
||||
DayOfWeek.Friday,
|
||||
DayOfWeek.Saturday,
|
||||
DayOfWeek.Sunday
|
||||
};
|
||||
|
||||
public static List<int> AllDaysOfMonth() => Enumerable.Range(1, 31).ToList();
|
||||
public static List<int> AllMonthsOfYear() => Enumerable.Range(1, 12).ToList();
|
||||
|
||||
public int Id { get; set; }
|
||||
public int PlayoutId { get; set; }
|
||||
public Playout Playout { get; set; }
|
||||
public int ProgramScheduleId { get; set; }
|
||||
public ProgramSchedule ProgramSchedule { get; set; }
|
||||
public int Index { get; set; }
|
||||
public ICollection<DayOfWeek> DaysOfWeek { get; set; }
|
||||
public ICollection<int> DaysOfMonth { get; set; }
|
||||
public ICollection<int> MonthsOfYear { get; set; }
|
||||
}
|
||||
8
ErsatzTV.Core/Errors/ScanIsNotRequired.cs
Normal file
8
ErsatzTV.Core/Errors/ScanIsNotRequired.cs
Normal file
@@ -0,0 +1,8 @@
|
||||
namespace ErsatzTV.Core.Errors;
|
||||
|
||||
public class ScanIsNotRequired : BaseError
|
||||
{
|
||||
public ScanIsNotRequired() : base("Scan is not required")
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -39,12 +39,6 @@ public class FFmpegPlaybackSettingsCalculator
|
||||
"+igndts"
|
||||
};
|
||||
|
||||
public FFmpegPlaybackSettings ConcatSettings => new()
|
||||
{
|
||||
ThreadCount = 1,
|
||||
FormatFlags = CommonFormatFlags
|
||||
};
|
||||
|
||||
public FFmpegPlaybackSettings CalculateSettings(
|
||||
StreamingMode streamingMode,
|
||||
FFmpegProfile ffmpegProfile,
|
||||
@@ -181,7 +175,8 @@ public class FFmpegPlaybackSettingsCalculator
|
||||
bool hlsRealtime) =>
|
||||
new()
|
||||
{
|
||||
HardwareAcceleration = ffmpegProfile.HardwareAcceleration,
|
||||
// HardwareAcceleration = ffmpegProfile.HardwareAcceleration,
|
||||
HardwareAcceleration = HardwareAccelerationKind.None,
|
||||
FormatFlags = CommonFormatFlags,
|
||||
VideoFormat = ffmpegProfile.VideoFormat,
|
||||
VideoBitrate = ffmpegProfile.VideoBitrate,
|
||||
|
||||
20
ErsatzTV.Core/FFmpeg/FFmpegProcess.cs
Normal file
20
ErsatzTV.Core/FFmpeg/FFmpegProcess.cs
Normal file
@@ -0,0 +1,20 @@
|
||||
using System.Diagnostics;
|
||||
|
||||
namespace ErsatzTV.Core.FFmpeg;
|
||||
|
||||
public class FFmpegProcess : Process
|
||||
{
|
||||
public static int ProcessCount;
|
||||
|
||||
public FFmpegProcess()
|
||||
{
|
||||
Interlocked.Increment(ref ProcessCount);
|
||||
}
|
||||
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
base.Dispose(disposing);
|
||||
|
||||
Interlocked.Decrement(ref ProcessCount);
|
||||
}
|
||||
}
|
||||
@@ -19,5 +19,4 @@ public interface IArtistRepository
|
||||
Task<bool> AddStyle(ArtistMetadata metadata, Style style);
|
||||
Task<bool> AddMood(ArtistMetadata metadata, Mood mood);
|
||||
Task<List<MusicVideo>> GetArtistItems(int artistId);
|
||||
Task<List<Artist>> GetAllArtists();
|
||||
}
|
||||
|
||||
@@ -203,7 +203,7 @@ public class PlayoutBuilder : IPlayoutBuilder
|
||||
parameters.Start,
|
||||
parameters.Finish,
|
||||
parameters.CollectionMediaItems,
|
||||
playout.ProgramSchedule.RandomStartPoint);
|
||||
true);
|
||||
|
||||
return playout;
|
||||
}
|
||||
@@ -237,11 +237,7 @@ public class PlayoutBuilder : IPlayoutBuilder
|
||||
Map<CollectionKey, List<MediaItem>> collectionMediaItems = await GetCollectionMediaItems(playout);
|
||||
if (!collectionMediaItems.Any())
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Playout {Playout} schedule {Schedule} has no items",
|
||||
playout.Channel.Name,
|
||||
playout.ProgramSchedule.Name);
|
||||
|
||||
_logger.LogWarning("Playout {Playout} has no items", playout.Channel.Name);
|
||||
return None;
|
||||
}
|
||||
|
||||
@@ -365,13 +361,21 @@ public class PlayoutBuilder : IPlayoutBuilder
|
||||
bool saveAnchorDate,
|
||||
bool randomStartPoint)
|
||||
{
|
||||
var sortedScheduleItems = playout.ProgramSchedule.Items.OrderBy(i => i.Index).ToList();
|
||||
ProgramSchedule activeSchedule = PlayoutScheduleSelector.GetProgramScheduleFor(
|
||||
playout.ProgramSchedule,
|
||||
playout.ProgramScheduleAlternates,
|
||||
playoutStart);
|
||||
|
||||
// random start points are disabled in some scenarios, so ensure it's enabled and active
|
||||
randomStartPoint = randomStartPoint && activeSchedule.RandomStartPoint;
|
||||
|
||||
var sortedScheduleItems = activeSchedule.Items.OrderBy(i => i.Index).ToList();
|
||||
CollectionEnumeratorState scheduleItemsEnumeratorState =
|
||||
playout.Anchor?.ScheduleItemsEnumeratorState ?? new CollectionEnumeratorState
|
||||
{ Seed = Random.Next(), Index = 0 };
|
||||
IScheduleItemsEnumerator scheduleItemsEnumerator = playout.ProgramSchedule.ShuffleScheduleItems
|
||||
? new ShuffledScheduleItemsEnumerator(playout.ProgramSchedule.Items, scheduleItemsEnumeratorState)
|
||||
: new OrderedScheduleItemsEnumerator(playout.ProgramSchedule.Items, scheduleItemsEnumeratorState);
|
||||
IScheduleItemsEnumerator scheduleItemsEnumerator = activeSchedule.ShuffleScheduleItems
|
||||
? new ShuffledScheduleItemsEnumerator(activeSchedule.Items, scheduleItemsEnumeratorState)
|
||||
: new OrderedScheduleItemsEnumerator(activeSchedule.Items, scheduleItemsEnumeratorState);
|
||||
var collectionEnumerators = new Dictionary<CollectionKey, IMediaCollectionEnumerator>();
|
||||
foreach ((CollectionKey collectionKey, List<MediaItem> mediaItems) in collectionMediaItems)
|
||||
{
|
||||
@@ -381,7 +385,13 @@ public class PlayoutBuilder : IPlayoutBuilder
|
||||
PlaybackOrder playbackOrder = maybeScheduleItem
|
||||
.Match(item => item.PlaybackOrder, () => PlaybackOrder.Shuffle);
|
||||
IMediaCollectionEnumerator enumerator =
|
||||
await GetMediaCollectionEnumerator(playout, collectionKey, mediaItems, playbackOrder, randomStartPoint);
|
||||
await GetMediaCollectionEnumerator(
|
||||
playout,
|
||||
activeSchedule,
|
||||
collectionKey,
|
||||
mediaItems,
|
||||
playbackOrder,
|
||||
randomStartPoint);
|
||||
collectionEnumerators.Add(collectionKey, enumerator);
|
||||
}
|
||||
|
||||
@@ -533,7 +543,11 @@ public class PlayoutBuilder : IPlayoutBuilder
|
||||
}
|
||||
|
||||
// build program schedule anchors
|
||||
playout.ProgramScheduleAnchors = BuildProgramScheduleAnchors(playout, collectionEnumerators, saveAnchorDate);
|
||||
playout.ProgramScheduleAnchors = BuildProgramScheduleAnchors(
|
||||
playout,
|
||||
activeSchedule,
|
||||
collectionEnumerators,
|
||||
saveAnchorDate);
|
||||
|
||||
return playout;
|
||||
}
|
||||
@@ -541,6 +555,8 @@ public class PlayoutBuilder : IPlayoutBuilder
|
||||
private async Task<Map<CollectionKey, List<MediaItem>>> GetCollectionMediaItems(Playout playout)
|
||||
{
|
||||
var collectionKeys = playout.ProgramSchedule.Items
|
||||
.Append(playout.ProgramScheduleAlternates.Bind(psa => psa.ProgramSchedule.Items))
|
||||
.DistinctBy(i => i.Id)
|
||||
.SelectMany(CollectionKeysForItem)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
@@ -640,6 +656,7 @@ public class PlayoutBuilder : IPlayoutBuilder
|
||||
|
||||
private static List<PlayoutProgramScheduleAnchor> BuildProgramScheduleAnchors(
|
||||
Playout playout,
|
||||
ProgramSchedule activeSchedule,
|
||||
Dictionary<CollectionKey, IMediaCollectionEnumerator> collectionEnumerators,
|
||||
bool saveAnchorDate)
|
||||
{
|
||||
@@ -651,6 +668,8 @@ public class PlayoutBuilder : IPlayoutBuilder
|
||||
a => a.CollectionType == collectionKey.CollectionType
|
||||
&& a.CollectionId == collectionKey.CollectionId
|
||||
&& a.MediaItemId == collectionKey.MediaItemId
|
||||
&& a.SmartCollectionId == collectionKey.SmartCollectionId
|
||||
&& a.MultiCollectionId == collectionKey.MultiCollectionId
|
||||
&& a.AnchorDate is null);
|
||||
|
||||
var maybeEnumeratorState = collectionEnumerators.ToDictionary(e => e.Key, e => e.Value.State);
|
||||
@@ -665,8 +684,8 @@ public class PlayoutBuilder : IPlayoutBuilder
|
||||
{
|
||||
Playout = playout,
|
||||
PlayoutId = playout.Id,
|
||||
ProgramSchedule = playout.ProgramSchedule,
|
||||
ProgramScheduleId = playout.ProgramScheduleId,
|
||||
ProgramSchedule = activeSchedule,
|
||||
ProgramScheduleId = activeSchedule.Id,
|
||||
CollectionType = collectionKey.CollectionType,
|
||||
CollectionId = collectionKey.CollectionId,
|
||||
MultiCollectionId = collectionKey.MultiCollectionId,
|
||||
@@ -694,6 +713,7 @@ public class PlayoutBuilder : IPlayoutBuilder
|
||||
|
||||
private async Task<IMediaCollectionEnumerator> GetMediaCollectionEnumerator(
|
||||
Playout playout,
|
||||
ProgramSchedule activeSchedule,
|
||||
CollectionKey collectionKey,
|
||||
List<MediaItem> mediaItems,
|
||||
PlaybackOrder playbackOrder,
|
||||
@@ -702,34 +722,38 @@ public class PlayoutBuilder : IPlayoutBuilder
|
||||
Option<PlayoutProgramScheduleAnchor> maybeAnchor = playout.ProgramScheduleAnchors
|
||||
.OrderByDescending(a => a.AnchorDate ?? DateTime.MaxValue)
|
||||
.FirstOrDefault(
|
||||
a => a.ProgramScheduleId == playout.ProgramScheduleId
|
||||
a => a.ProgramScheduleId == activeSchedule.Id
|
||||
&& a.CollectionType == collectionKey.CollectionType
|
||||
&& a.CollectionId == collectionKey.CollectionId
|
||||
&& a.MultiCollectionId == collectionKey.MultiCollectionId
|
||||
&& a.SmartCollectionId == collectionKey.SmartCollectionId
|
||||
&& a.MediaItemId == collectionKey.MediaItemId);
|
||||
|
||||
// foreach (PlayoutProgramScheduleAnchor anchor in maybeAnchor)
|
||||
// {
|
||||
// _logger.LogDebug("Selecting anchor {@Anchor}", anchor);
|
||||
// }
|
||||
CollectionEnumeratorState state = null;
|
||||
|
||||
CollectionEnumeratorState state = maybeAnchor.Match(
|
||||
anchor => anchor.EnumeratorState ??
|
||||
(anchor.EnumeratorState = new CollectionEnumeratorState { Seed = Random.Next(), Index = 0 }),
|
||||
() => new CollectionEnumeratorState { Seed = Random.Next(), Index = 0 });
|
||||
|
||||
if (await _mediaCollectionRepository.IsCustomPlaybackOrder(collectionKey.CollectionId ?? 0))
|
||||
foreach (PlayoutProgramScheduleAnchor anchor in maybeAnchor)
|
||||
{
|
||||
Option<Collection> collectionWithItems =
|
||||
await _mediaCollectionRepository.GetCollectionWithCollectionItemsUntracked(
|
||||
collectionKey.CollectionId ?? 0);
|
||||
// _logger.LogDebug("Selecting anchor {@Anchor}", anchor);
|
||||
|
||||
if (collectionKey.CollectionType == ProgramScheduleItemCollectionType.Collection &&
|
||||
collectionWithItems.IsSome)
|
||||
anchor.EnumeratorState ??= new CollectionEnumeratorState { Seed = Random.Next(), Index = 0 };
|
||||
|
||||
state = anchor.EnumeratorState;
|
||||
}
|
||||
|
||||
state ??= new CollectionEnumeratorState { Seed = Random.Next(), Index = 0 };
|
||||
|
||||
int collectionId = collectionKey.CollectionId ?? 0;
|
||||
|
||||
if (collectionKey.CollectionType == ProgramScheduleItemCollectionType.Collection &&
|
||||
await _mediaCollectionRepository.IsCustomPlaybackOrder(collectionId))
|
||||
{
|
||||
Option<Collection> maybeCollectionWithItems =
|
||||
await _mediaCollectionRepository.GetCollectionWithCollectionItemsUntracked(collectionId);
|
||||
|
||||
foreach (Collection collectionWithItems in maybeCollectionWithItems)
|
||||
{
|
||||
return new CustomOrderCollectionEnumerator(
|
||||
collectionWithItems.ValueUnsafe(),
|
||||
collectionWithItems,
|
||||
mediaItems,
|
||||
state);
|
||||
}
|
||||
@@ -757,7 +781,7 @@ public class PlayoutBuilder : IPlayoutBuilder
|
||||
return new ShuffleInOrderCollectionEnumerator(
|
||||
await GetCollectionItemsForShuffleInOrder(collectionKey),
|
||||
state,
|
||||
playout.ProgramSchedule.RandomStartPoint);
|
||||
activeSchedule.RandomStartPoint);
|
||||
case PlaybackOrder.MultiEpisodeShuffle when
|
||||
collectionKey.CollectionType == ProgramScheduleItemCollectionType.TelevisionShow &&
|
||||
collectionKey.MediaItemId.HasValue:
|
||||
@@ -795,7 +819,7 @@ public class PlayoutBuilder : IPlayoutBuilder
|
||||
case PlaybackOrder.MultiEpisodeShuffle:
|
||||
case PlaybackOrder.Shuffle:
|
||||
return new ShuffledMediaCollectionEnumerator(
|
||||
await GetGroupedMediaItemsForShuffle(playout, mediaItems, collectionKey),
|
||||
await GetGroupedMediaItemsForShuffle(activeSchedule, mediaItems, collectionKey),
|
||||
state);
|
||||
default:
|
||||
// TODO: handle this error case differently?
|
||||
@@ -804,7 +828,7 @@ public class PlayoutBuilder : IPlayoutBuilder
|
||||
}
|
||||
|
||||
private async Task<List<GroupedMediaItem>> GetGroupedMediaItemsForShuffle(
|
||||
Playout playout,
|
||||
ProgramSchedule activeSchedule,
|
||||
List<MediaItem> mediaItems,
|
||||
CollectionKey collectionKey)
|
||||
{
|
||||
@@ -816,10 +840,8 @@ public class PlayoutBuilder : IPlayoutBuilder
|
||||
return MultiCollectionGrouper.GroupMediaItems(collections);
|
||||
}
|
||||
|
||||
return playout.ProgramSchedule.KeepMultiPartEpisodesTogether
|
||||
? MultiPartEpisodeGrouper.GroupMediaItems(
|
||||
mediaItems,
|
||||
playout.ProgramSchedule.TreatCollectionsAsShows)
|
||||
return activeSchedule.KeepMultiPartEpisodesTogether
|
||||
? MultiPartEpisodeGrouper.GroupMediaItems(mediaItems, activeSchedule.TreatCollectionsAsShows)
|
||||
: mediaItems.Map(mi => new GroupedMediaItem(mi, null)).ToList();
|
||||
}
|
||||
|
||||
|
||||
@@ -91,15 +91,18 @@ public class PlayoutModeSchedulerFlood : PlayoutModeSchedulerBase<ProgramSchedul
|
||||
AddFiller(nextState, collectionEnumerators, scheduleItem, playoutItem, itemChapters));
|
||||
// LogScheduledItem(scheduleItem, mediaItem, itemStartTime);
|
||||
|
||||
DateTimeOffset actualEndTime = playoutItems.Max(p => p.FinishOffset);
|
||||
if (Math.Abs((itemEndTimeWithFiller - actualEndTime).TotalSeconds) > 1)
|
||||
if (playoutItems.Count > 0)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Filler prediction failure: predicted {PredictedDuration} doesn't match actual {ActualDuration}",
|
||||
itemEndTimeWithFiller,
|
||||
actualEndTime);
|
||||
DateTimeOffset actualEndTime = playoutItems.Max(p => p.FinishOffset);
|
||||
if (Math.Abs((itemEndTimeWithFiller - actualEndTime).TotalSeconds) > 1)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Filler prediction failure: predicted {PredictedDuration} doesn't match actual {ActualDuration}",
|
||||
itemEndTimeWithFiller,
|
||||
actualEndTime);
|
||||
|
||||
// _logger.LogWarning("Playout items: {@PlayoutItems}", playoutItems);
|
||||
// _logger.LogWarning("Playout items: {@PlayoutItems}", playoutItems);
|
||||
}
|
||||
}
|
||||
|
||||
nextState = nextState with
|
||||
|
||||
40
ErsatzTV.Core/Scheduling/PlayoutScheduleSelector.cs
Normal file
40
ErsatzTV.Core/Scheduling/PlayoutScheduleSelector.cs
Normal file
@@ -0,0 +1,40 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
|
||||
namespace ErsatzTV.Core.Scheduling;
|
||||
|
||||
public static class PlayoutScheduleSelector
|
||||
{
|
||||
public static ProgramSchedule GetProgramScheduleFor(
|
||||
ProgramSchedule defaultSchedule,
|
||||
IEnumerable<ProgramScheduleAlternate> alternates,
|
||||
DateTimeOffset date)
|
||||
{
|
||||
foreach (ProgramScheduleAlternate alternate in alternates.OrderBy(x => x.Index))
|
||||
{
|
||||
bool daysOfWeek = alternate.DaysOfWeek.Count is 0 or 7 ||
|
||||
alternate.DaysOfWeek.Contains(date.DayOfWeek);
|
||||
|
||||
if (!daysOfWeek)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
bool daysOfMonth = alternate.DaysOfMonth.Count is 0 or 31 ||
|
||||
alternate.DaysOfMonth.Contains(date.Day);
|
||||
if (!daysOfMonth)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
bool monthOfYear = alternate.MonthsOfYear.Count is 0 or 12 ||
|
||||
alternate.MonthsOfYear.Contains(date.Month);
|
||||
|
||||
if (monthOfYear)
|
||||
{
|
||||
return alternate.ProgramSchedule;
|
||||
}
|
||||
}
|
||||
|
||||
return defaultSchedule;
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,7 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FluentAssertions" Version="6.8.0" />
|
||||
<PackageReference Include="FluentAssertions" Version="6.9.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="7.0.0" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.4.1" />
|
||||
<PackageReference Include="Moq" Version="4.18.4" />
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FluentAssertions" Version="6.8.0" />
|
||||
<PackageReference Include="FluentAssertions" Version="6.9.0" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.4.1" />
|
||||
<PackageReference Include="Moq" Version="4.18.4" />
|
||||
<PackageReference Include="NUnit" Version="3.13.3" />
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
using Microsoft.EntityFrameworkCore.ChangeTracking;
|
||||
|
||||
namespace ErsatzTV.Infrastructure.Data.Configurations;
|
||||
|
||||
public class CollectionValueComparer<T> : ValueComparer<ICollection<T>>
|
||||
{
|
||||
public CollectionValueComparer() : base(
|
||||
(c1, c2) => c1.SequenceEqual(c2),
|
||||
c => c.Aggregate(0, (a, v) => HashCode.Combine(a, v.GetHashCode())),
|
||||
c => c.ToHashSet())
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace ErsatzTV.Infrastructure.Data.Configurations;
|
||||
|
||||
public class EnumCollectionJsonValueConverter<T> : ValueConverter<ICollection<T>, string> where T : Enum
|
||||
{
|
||||
public EnumCollectionJsonValueConverter() : base(
|
||||
v => JsonConvert.SerializeObject(v.Select(e => e.ToString()).ToList()),
|
||||
v => JsonConvert.DeserializeObject<ICollection<string>>(v)
|
||||
.Select(e => (T)Enum.Parse(typeof(T), e)).ToList())
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,26 @@ public class ArtistMetadataConfiguration : IEntityTypeConfiguration<ArtistMetada
|
||||
.WithOne()
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
builder.HasMany(sm => sm.Tags)
|
||||
.WithOne()
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
builder.HasMany(sm => sm.Studios)
|
||||
.WithOne()
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
builder.HasMany(sm => sm.Actors)
|
||||
.WithOne()
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
builder.HasMany(mm => mm.Guids)
|
||||
.WithOne()
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
builder.HasMany(mm => mm.Subtitles)
|
||||
.WithOne()
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
builder.HasMany(sm => sm.Styles)
|
||||
.WithOne()
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
@@ -10,19 +10,23 @@ public class EpisodeMetadataConfiguration : IEntityTypeConfiguration<EpisodeMeta
|
||||
{
|
||||
builder.ToTable("EpisodeMetadata");
|
||||
|
||||
builder.HasMany(em => em.Artwork)
|
||||
builder.HasMany(sm => sm.Artwork)
|
||||
.WithOne()
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
builder.HasMany(em => em.Actors)
|
||||
builder.HasMany(sm => sm.Genres)
|
||||
.WithOne()
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
builder.HasMany(mm => mm.Directors)
|
||||
builder.HasMany(sm => sm.Tags)
|
||||
.WithOne()
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
builder.HasMany(mm => mm.Writers)
|
||||
builder.HasMany(sm => sm.Studios)
|
||||
.WithOne()
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
builder.HasMany(sm => sm.Actors)
|
||||
.WithOne()
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
@@ -30,7 +34,15 @@ public class EpisodeMetadataConfiguration : IEntityTypeConfiguration<EpisodeMeta
|
||||
.WithOne()
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
builder.HasMany(em => em.Subtitles)
|
||||
builder.HasMany(mm => mm.Subtitles)
|
||||
.WithOne()
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
builder.HasMany(mm => mm.Directors)
|
||||
.WithOne()
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
builder.HasMany(mm => mm.Writers)
|
||||
.WithOne()
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
}
|
||||
|
||||
@@ -10,31 +10,23 @@ public class MovieMetadataConfiguration : IEntityTypeConfiguration<MovieMetadata
|
||||
{
|
||||
builder.ToTable("MovieMetadata");
|
||||
|
||||
builder.HasMany(mm => mm.Artwork)
|
||||
builder.HasMany(sm => sm.Artwork)
|
||||
.WithOne()
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
builder.HasMany(mm => mm.Genres)
|
||||
builder.HasMany(sm => sm.Genres)
|
||||
.WithOne()
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
builder.HasMany(mm => mm.Tags)
|
||||
builder.HasMany(sm => sm.Tags)
|
||||
.WithOne()
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
builder.HasMany(mm => mm.Studios)
|
||||
builder.HasMany(sm => sm.Studios)
|
||||
.WithOne()
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
builder.HasMany(mm => mm.Actors)
|
||||
.WithOne()
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
builder.HasMany(mm => mm.Directors)
|
||||
.WithOne()
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
builder.HasMany(mm => mm.Writers)
|
||||
builder.HasMany(sm => sm.Actors)
|
||||
.WithOne()
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
@@ -45,5 +37,13 @@ public class MovieMetadataConfiguration : IEntityTypeConfiguration<MovieMetadata
|
||||
builder.HasMany(mm => mm.Subtitles)
|
||||
.WithOne()
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
builder.HasMany(mm => mm.Directors)
|
||||
.WithOne()
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
builder.HasMany(mm => mm.Writers)
|
||||
.WithOne()
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,23 +10,31 @@ public class MusicVideoMetadataConfiguration : IEntityTypeConfiguration<MusicVid
|
||||
{
|
||||
builder.ToTable("MusicVideoMetadata");
|
||||
|
||||
builder.HasMany(mm => mm.Artwork)
|
||||
builder.HasMany(sm => sm.Artwork)
|
||||
.WithOne()
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
builder.HasMany(mm => mm.Genres)
|
||||
builder.HasMany(sm => sm.Genres)
|
||||
.WithOne()
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
builder.HasMany(mm => mm.Tags)
|
||||
builder.HasMany(sm => sm.Tags)
|
||||
.WithOne()
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
builder.HasMany(mm => mm.Studios)
|
||||
builder.HasMany(sm => sm.Studios)
|
||||
.WithOne()
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
builder.HasMany(mvm => mvm.Subtitles)
|
||||
builder.HasMany(sm => sm.Actors)
|
||||
.WithOne()
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
builder.HasMany(mm => mm.Guids)
|
||||
.WithOne()
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
builder.HasMany(mm => mm.Subtitles)
|
||||
.WithOne()
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
|
||||
@@ -10,26 +10,34 @@ public class OtherVideoMetadataConfiguration : IEntityTypeConfiguration<OtherVid
|
||||
{
|
||||
builder.ToTable("OtherVideoMetadata");
|
||||
|
||||
builder.HasMany(ovm => ovm.Artwork)
|
||||
builder.HasMany(sm => sm.Artwork)
|
||||
.WithOne()
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
builder.HasMany(ovm => ovm.Genres)
|
||||
builder.HasMany(sm => sm.Genres)
|
||||
.WithOne()
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
builder.HasMany(ovm => ovm.Tags)
|
||||
builder.HasMany(sm => sm.Tags)
|
||||
.WithOne()
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
builder.HasMany(ovm => ovm.Studios)
|
||||
builder.HasMany(sm => sm.Studios)
|
||||
.WithOne()
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
builder.HasMany(ovm => ovm.Actors)
|
||||
builder.HasMany(sm => sm.Actors)
|
||||
.WithOne()
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
builder.HasMany(mm => mm.Guids)
|
||||
.WithOne()
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
builder.HasMany(mm => mm.Subtitles)
|
||||
.WithOne()
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
builder.HasMany(ovm => ovm.Directors)
|
||||
.WithOne()
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
@@ -37,13 +45,5 @@ public class OtherVideoMetadataConfiguration : IEntityTypeConfiguration<OtherVid
|
||||
builder.HasMany(ovm => ovm.Writers)
|
||||
.WithOne()
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
builder.HasMany(ovm => ovm.Guids)
|
||||
.WithOne()
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
builder.HasMany(ovm => ovm.Subtitles)
|
||||
.WithOne()
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,12 +10,32 @@ public class SeasonMetadataConfiguration : IEntityTypeConfiguration<SeasonMetada
|
||||
{
|
||||
builder.ToTable("SeasonMetadata");
|
||||
|
||||
builder.HasMany(em => em.Artwork)
|
||||
builder.HasMany(sm => sm.Artwork)
|
||||
.WithOne()
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
builder.HasMany(sm => sm.Genres)
|
||||
.WithOne()
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
builder.HasMany(sm => sm.Tags)
|
||||
.WithOne()
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
builder.HasMany(sm => sm.Studios)
|
||||
.WithOne()
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
builder.HasMany(sm => sm.Actors)
|
||||
.WithOne()
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
builder.HasMany(mm => mm.Guids)
|
||||
.WithOne()
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
builder.HasMany(mm => mm.Subtitles)
|
||||
.WithOne()
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,5 +33,9 @@ public class ShowMetadataConfiguration : IEntityTypeConfiguration<ShowMetadata>
|
||||
builder.HasMany(mm => mm.Guids)
|
||||
.WithOne()
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
builder.HasMany(mm => mm.Subtitles)
|
||||
.WithOne()
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,19 +10,31 @@ public class SongMetadataConfiguration : IEntityTypeConfiguration<SongMetadata>
|
||||
{
|
||||
builder.ToTable("SongMetadata");
|
||||
|
||||
builder.HasMany(mm => mm.Artwork)
|
||||
builder.HasMany(sm => sm.Artwork)
|
||||
.WithOne()
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
builder.HasMany(mm => mm.Genres)
|
||||
builder.HasMany(sm => sm.Genres)
|
||||
.WithOne()
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
builder.HasMany(mm => mm.Tags)
|
||||
builder.HasMany(sm => sm.Tags)
|
||||
.WithOne()
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
builder.HasMany(mm => mm.Studios)
|
||||
builder.HasMany(sm => sm.Studios)
|
||||
.WithOne()
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
builder.HasMany(sm => sm.Actors)
|
||||
.WithOne()
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
builder.HasMany(mm => mm.Guids)
|
||||
.WithOne()
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
builder.HasMany(mm => mm.Subtitles)
|
||||
.WithOne()
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
}
|
||||
|
||||
@@ -10,6 +10,11 @@ public class PlayoutConfiguration : IEntityTypeConfiguration<Playout>
|
||||
{
|
||||
builder.ToTable("Playout");
|
||||
|
||||
builder.HasMany(p => p.ProgramScheduleAlternates)
|
||||
.WithOne(a => a.Playout)
|
||||
.HasForeignKey(a => a.PlayoutId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
builder.HasMany(p => p.Items)
|
||||
.WithOne(pi => pi.Playout)
|
||||
.HasForeignKey(pi => pi.PlayoutId)
|
||||
|
||||
@@ -24,6 +24,12 @@ public class PlayoutProgramScheduleAnchorConfiguration : IEntityTypeConfiguratio
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired(false);
|
||||
|
||||
builder.HasOne(i => i.SmartCollection)
|
||||
.WithMany()
|
||||
.HasForeignKey(i => i.SmartCollectionId)
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired(false);
|
||||
|
||||
builder.HasOne(i => i.MediaItem)
|
||||
.WithMany()
|
||||
.HasForeignKey(i => i.MediaItemId)
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
|
||||
namespace ErsatzTV.Infrastructure.Data.Configurations;
|
||||
|
||||
public class ProgramScheduleAlternateConfiguration : IEntityTypeConfiguration<ProgramScheduleAlternate>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<ProgramScheduleAlternate> builder)
|
||||
{
|
||||
builder.ToTable("ProgramScheduleAlternate");
|
||||
|
||||
var intCollectionValueConverter = new ValueConverter<ICollection<int>, string>(
|
||||
i => string.Join(",", i),
|
||||
s => string.IsNullOrWhiteSpace(s)
|
||||
? Array.Empty<int>()
|
||||
: s.Split(new[] { ',' }).Select(int.Parse).ToArray());
|
||||
|
||||
var intCollectionValueComparer = new CollectionValueComparer<int>();
|
||||
|
||||
builder.Property(t => t.DaysOfMonth)
|
||||
.HasConversion(intCollectionValueConverter)
|
||||
.Metadata.SetValueComparer(intCollectionValueComparer);
|
||||
|
||||
builder.Property(t => t.MonthsOfYear)
|
||||
.HasConversion(intCollectionValueConverter)
|
||||
.Metadata.SetValueComparer(intCollectionValueComparer);
|
||||
|
||||
builder.Property(t => t.DaysOfWeek)
|
||||
.HasConversion(new EnumCollectionJsonValueConverter<DayOfWeek>())
|
||||
.Metadata.SetValueComparer(new CollectionValueComparer<DayOfWeek>());
|
||||
}
|
||||
}
|
||||
@@ -22,5 +22,10 @@ public class ProgramScheduleConfiguration : IEntityTypeConfiguration<ProgramSche
|
||||
.WithOne(p => p.ProgramSchedule)
|
||||
.HasForeignKey(p => p.ProgramScheduleId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
builder.HasMany(p => p.ProgramScheduleAlternates)
|
||||
.WithOne(a => a.ProgramSchedule)
|
||||
.HasForeignKey(a => a.ProgramScheduleId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -160,14 +160,4 @@ public class ArtistRepository : IArtistRepository
|
||||
.Filter(mv => mv.ArtistId == artistId)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<List<Artist>> GetAllArtists()
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
|
||||
return await dbContext.Artists
|
||||
.AsNoTracking()
|
||||
.Include(a => a.ArtistMetadata)
|
||||
.ThenInclude(am => am.Artwork)
|
||||
.ToListAsync();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,6 +76,7 @@ public class TvContext : DbContext
|
||||
public DbSet<ProgramSchedule> ProgramSchedules { get; set; }
|
||||
public DbSet<ProgramScheduleItem> ProgramScheduleItems { get; set; }
|
||||
public DbSet<Playout> Playouts { get; set; }
|
||||
public DbSet<ProgramScheduleAlternate> ProgramScheduleAlternates { get; set; }
|
||||
public DbSet<PlayoutItem> PlayoutItems { get; set; }
|
||||
public DbSet<PlayoutProgramScheduleAnchor> PlayoutProgramScheduleItemAnchors { get; set; }
|
||||
public DbSet<FFmpegProfile> FFmpegProfiles { get; set; }
|
||||
|
||||
@@ -11,16 +11,16 @@
|
||||
<PackageReference Include="Blurhash.ImageSharp" Version="3.0.0" />
|
||||
<PackageReference Include="CliWrap" Version="3.6.0" />
|
||||
<PackageReference Include="Dapper" Version="2.0.123" />
|
||||
<PackageReference Include="Jint" Version="3.0.0-beta-2044" />
|
||||
<PackageReference Include="Jint" Version="3.0.0-beta-2046" />
|
||||
<PackageReference Include="Lucene.Net" Version="4.8.0-beta00016" />
|
||||
<PackageReference Include="Lucene.Net.Analysis.Common" Version="4.8.0-beta00016" />
|
||||
<PackageReference Include="Lucene.Net.QueryParser" Version="4.8.0-beta00016" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="7.0.1" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="7.0.1">
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="7.0.2" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="7.0.2">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="7.0.1" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="7.0.2" />
|
||||
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="17.4.33">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
|
||||
4394
ErsatzTV.Infrastructure/Migrations/20230108192830_Add_ProgramScheduleAlternates.Designer.cs
generated
Normal file
4394
ErsatzTV.Infrastructure/Migrations/20230108192830_Add_ProgramScheduleAlternates.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,61 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ErsatzTV.Infrastructure.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddProgramScheduleAlternates : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "ProgramScheduleAlternate",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "INTEGER", nullable: false)
|
||||
.Annotation("Sqlite:Autoincrement", true),
|
||||
PlayoutId = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
ProgramScheduleId = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
Index = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
DaysOfWeek = table.Column<string>(type: "TEXT", nullable: true),
|
||||
DaysOfMonth = table.Column<string>(type: "TEXT", nullable: true),
|
||||
MonthsOfYear = table.Column<string>(type: "TEXT", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_ProgramScheduleAlternate", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_ProgramScheduleAlternate_Playout_PlayoutId",
|
||||
column: x => x.PlayoutId,
|
||||
principalTable: "Playout",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
table.ForeignKey(
|
||||
name: "FK_ProgramScheduleAlternate_ProgramSchedule_ProgramScheduleId",
|
||||
column: x => x.ProgramScheduleId,
|
||||
principalTable: "ProgramSchedule",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ProgramScheduleAlternate_PlayoutId",
|
||||
table: "ProgramScheduleAlternate",
|
||||
column: "PlayoutId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ProgramScheduleAlternate_ProgramScheduleId",
|
||||
table: "ProgramScheduleAlternate",
|
||||
column: "ProgramScheduleId");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "ProgramScheduleAlternate");
|
||||
}
|
||||
}
|
||||
}
|
||||
4413
ErsatzTV.Infrastructure/Migrations/20230120151742_Add_AllMetadataKeys.Designer.cs
generated
Normal file
4413
ErsatzTV.Infrastructure/Migrations/20230120151742_Add_AllMetadataKeys.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,455 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ErsatzTV.Infrastructure.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddAllMetadataKeys : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_Actor_ArtistMetadata_ArtistMetadataId",
|
||||
table: "Actor");
|
||||
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_Actor_MusicVideoMetadata_MusicVideoMetadataId",
|
||||
table: "Actor");
|
||||
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_Actor_SeasonMetadata_SeasonMetadataId",
|
||||
table: "Actor");
|
||||
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_Actor_SongMetadata_SongMetadataId",
|
||||
table: "Actor");
|
||||
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_Genre_EpisodeMetadata_EpisodeMetadataId",
|
||||
table: "Genre");
|
||||
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_Genre_SeasonMetadata_SeasonMetadataId",
|
||||
table: "Genre");
|
||||
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_MetadataGuid_ArtistMetadata_ArtistMetadataId",
|
||||
table: "MetadataGuid");
|
||||
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_MetadataGuid_MusicVideoMetadata_MusicVideoMetadataId",
|
||||
table: "MetadataGuid");
|
||||
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_MetadataGuid_SongMetadata_SongMetadataId",
|
||||
table: "MetadataGuid");
|
||||
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_Studio_ArtistMetadata_ArtistMetadataId",
|
||||
table: "Studio");
|
||||
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_Studio_EpisodeMetadata_EpisodeMetadataId",
|
||||
table: "Studio");
|
||||
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_Studio_SeasonMetadata_SeasonMetadataId",
|
||||
table: "Studio");
|
||||
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_Subtitle_ArtistMetadata_ArtistMetadataId",
|
||||
table: "Subtitle");
|
||||
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_Subtitle_SeasonMetadata_SeasonMetadataId",
|
||||
table: "Subtitle");
|
||||
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_Subtitle_ShowMetadata_ShowMetadataId",
|
||||
table: "Subtitle");
|
||||
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_Subtitle_SongMetadata_SongMetadataId",
|
||||
table: "Subtitle");
|
||||
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_Tag_ArtistMetadata_ArtistMetadataId",
|
||||
table: "Tag");
|
||||
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_Tag_EpisodeMetadata_EpisodeMetadataId",
|
||||
table: "Tag");
|
||||
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_Tag_SeasonMetadata_SeasonMetadataId",
|
||||
table: "Tag");
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_Actor_ArtistMetadata_ArtistMetadataId",
|
||||
table: "Actor",
|
||||
column: "ArtistMetadataId",
|
||||
principalTable: "ArtistMetadata",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_Actor_MusicVideoMetadata_MusicVideoMetadataId",
|
||||
table: "Actor",
|
||||
column: "MusicVideoMetadataId",
|
||||
principalTable: "MusicVideoMetadata",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_Actor_SeasonMetadata_SeasonMetadataId",
|
||||
table: "Actor",
|
||||
column: "SeasonMetadataId",
|
||||
principalTable: "SeasonMetadata",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_Actor_SongMetadata_SongMetadataId",
|
||||
table: "Actor",
|
||||
column: "SongMetadataId",
|
||||
principalTable: "SongMetadata",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_Genre_EpisodeMetadata_EpisodeMetadataId",
|
||||
table: "Genre",
|
||||
column: "EpisodeMetadataId",
|
||||
principalTable: "EpisodeMetadata",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_Genre_SeasonMetadata_SeasonMetadataId",
|
||||
table: "Genre",
|
||||
column: "SeasonMetadataId",
|
||||
principalTable: "SeasonMetadata",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_MetadataGuid_ArtistMetadata_ArtistMetadataId",
|
||||
table: "MetadataGuid",
|
||||
column: "ArtistMetadataId",
|
||||
principalTable: "ArtistMetadata",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_MetadataGuid_MusicVideoMetadata_MusicVideoMetadataId",
|
||||
table: "MetadataGuid",
|
||||
column: "MusicVideoMetadataId",
|
||||
principalTable: "MusicVideoMetadata",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_MetadataGuid_SongMetadata_SongMetadataId",
|
||||
table: "MetadataGuid",
|
||||
column: "SongMetadataId",
|
||||
principalTable: "SongMetadata",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_Studio_ArtistMetadata_ArtistMetadataId",
|
||||
table: "Studio",
|
||||
column: "ArtistMetadataId",
|
||||
principalTable: "ArtistMetadata",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_Studio_EpisodeMetadata_EpisodeMetadataId",
|
||||
table: "Studio",
|
||||
column: "EpisodeMetadataId",
|
||||
principalTable: "EpisodeMetadata",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_Studio_SeasonMetadata_SeasonMetadataId",
|
||||
table: "Studio",
|
||||
column: "SeasonMetadataId",
|
||||
principalTable: "SeasonMetadata",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_Subtitle_ArtistMetadata_ArtistMetadataId",
|
||||
table: "Subtitle",
|
||||
column: "ArtistMetadataId",
|
||||
principalTable: "ArtistMetadata",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_Subtitle_SeasonMetadata_SeasonMetadataId",
|
||||
table: "Subtitle",
|
||||
column: "SeasonMetadataId",
|
||||
principalTable: "SeasonMetadata",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_Subtitle_ShowMetadata_ShowMetadataId",
|
||||
table: "Subtitle",
|
||||
column: "ShowMetadataId",
|
||||
principalTable: "ShowMetadata",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_Subtitle_SongMetadata_SongMetadataId",
|
||||
table: "Subtitle",
|
||||
column: "SongMetadataId",
|
||||
principalTable: "SongMetadata",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_Tag_ArtistMetadata_ArtistMetadataId",
|
||||
table: "Tag",
|
||||
column: "ArtistMetadataId",
|
||||
principalTable: "ArtistMetadata",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_Tag_EpisodeMetadata_EpisodeMetadataId",
|
||||
table: "Tag",
|
||||
column: "EpisodeMetadataId",
|
||||
principalTable: "EpisodeMetadata",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_Tag_SeasonMetadata_SeasonMetadataId",
|
||||
table: "Tag",
|
||||
column: "SeasonMetadataId",
|
||||
principalTable: "SeasonMetadata",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_Actor_ArtistMetadata_ArtistMetadataId",
|
||||
table: "Actor");
|
||||
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_Actor_MusicVideoMetadata_MusicVideoMetadataId",
|
||||
table: "Actor");
|
||||
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_Actor_SeasonMetadata_SeasonMetadataId",
|
||||
table: "Actor");
|
||||
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_Actor_SongMetadata_SongMetadataId",
|
||||
table: "Actor");
|
||||
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_Genre_EpisodeMetadata_EpisodeMetadataId",
|
||||
table: "Genre");
|
||||
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_Genre_SeasonMetadata_SeasonMetadataId",
|
||||
table: "Genre");
|
||||
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_MetadataGuid_ArtistMetadata_ArtistMetadataId",
|
||||
table: "MetadataGuid");
|
||||
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_MetadataGuid_MusicVideoMetadata_MusicVideoMetadataId",
|
||||
table: "MetadataGuid");
|
||||
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_MetadataGuid_SongMetadata_SongMetadataId",
|
||||
table: "MetadataGuid");
|
||||
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_Studio_ArtistMetadata_ArtistMetadataId",
|
||||
table: "Studio");
|
||||
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_Studio_EpisodeMetadata_EpisodeMetadataId",
|
||||
table: "Studio");
|
||||
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_Studio_SeasonMetadata_SeasonMetadataId",
|
||||
table: "Studio");
|
||||
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_Subtitle_ArtistMetadata_ArtistMetadataId",
|
||||
table: "Subtitle");
|
||||
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_Subtitle_SeasonMetadata_SeasonMetadataId",
|
||||
table: "Subtitle");
|
||||
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_Subtitle_ShowMetadata_ShowMetadataId",
|
||||
table: "Subtitle");
|
||||
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_Subtitle_SongMetadata_SongMetadataId",
|
||||
table: "Subtitle");
|
||||
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_Tag_ArtistMetadata_ArtistMetadataId",
|
||||
table: "Tag");
|
||||
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_Tag_EpisodeMetadata_EpisodeMetadataId",
|
||||
table: "Tag");
|
||||
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_Tag_SeasonMetadata_SeasonMetadataId",
|
||||
table: "Tag");
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_Actor_ArtistMetadata_ArtistMetadataId",
|
||||
table: "Actor",
|
||||
column: "ArtistMetadataId",
|
||||
principalTable: "ArtistMetadata",
|
||||
principalColumn: "Id");
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_Actor_MusicVideoMetadata_MusicVideoMetadataId",
|
||||
table: "Actor",
|
||||
column: "MusicVideoMetadataId",
|
||||
principalTable: "MusicVideoMetadata",
|
||||
principalColumn: "Id");
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_Actor_SeasonMetadata_SeasonMetadataId",
|
||||
table: "Actor",
|
||||
column: "SeasonMetadataId",
|
||||
principalTable: "SeasonMetadata",
|
||||
principalColumn: "Id");
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_Actor_SongMetadata_SongMetadataId",
|
||||
table: "Actor",
|
||||
column: "SongMetadataId",
|
||||
principalTable: "SongMetadata",
|
||||
principalColumn: "Id");
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_Genre_EpisodeMetadata_EpisodeMetadataId",
|
||||
table: "Genre",
|
||||
column: "EpisodeMetadataId",
|
||||
principalTable: "EpisodeMetadata",
|
||||
principalColumn: "Id");
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_Genre_SeasonMetadata_SeasonMetadataId",
|
||||
table: "Genre",
|
||||
column: "SeasonMetadataId",
|
||||
principalTable: "SeasonMetadata",
|
||||
principalColumn: "Id");
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_MetadataGuid_ArtistMetadata_ArtistMetadataId",
|
||||
table: "MetadataGuid",
|
||||
column: "ArtistMetadataId",
|
||||
principalTable: "ArtistMetadata",
|
||||
principalColumn: "Id");
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_MetadataGuid_MusicVideoMetadata_MusicVideoMetadataId",
|
||||
table: "MetadataGuid",
|
||||
column: "MusicVideoMetadataId",
|
||||
principalTable: "MusicVideoMetadata",
|
||||
principalColumn: "Id");
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_MetadataGuid_SongMetadata_SongMetadataId",
|
||||
table: "MetadataGuid",
|
||||
column: "SongMetadataId",
|
||||
principalTable: "SongMetadata",
|
||||
principalColumn: "Id");
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_Studio_ArtistMetadata_ArtistMetadataId",
|
||||
table: "Studio",
|
||||
column: "ArtistMetadataId",
|
||||
principalTable: "ArtistMetadata",
|
||||
principalColumn: "Id");
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_Studio_EpisodeMetadata_EpisodeMetadataId",
|
||||
table: "Studio",
|
||||
column: "EpisodeMetadataId",
|
||||
principalTable: "EpisodeMetadata",
|
||||
principalColumn: "Id");
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_Studio_SeasonMetadata_SeasonMetadataId",
|
||||
table: "Studio",
|
||||
column: "SeasonMetadataId",
|
||||
principalTable: "SeasonMetadata",
|
||||
principalColumn: "Id");
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_Subtitle_ArtistMetadata_ArtistMetadataId",
|
||||
table: "Subtitle",
|
||||
column: "ArtistMetadataId",
|
||||
principalTable: "ArtistMetadata",
|
||||
principalColumn: "Id");
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_Subtitle_SeasonMetadata_SeasonMetadataId",
|
||||
table: "Subtitle",
|
||||
column: "SeasonMetadataId",
|
||||
principalTable: "SeasonMetadata",
|
||||
principalColumn: "Id");
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_Subtitle_ShowMetadata_ShowMetadataId",
|
||||
table: "Subtitle",
|
||||
column: "ShowMetadataId",
|
||||
principalTable: "ShowMetadata",
|
||||
principalColumn: "Id");
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_Subtitle_SongMetadata_SongMetadataId",
|
||||
table: "Subtitle",
|
||||
column: "SongMetadataId",
|
||||
principalTable: "SongMetadata",
|
||||
principalColumn: "Id");
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_Tag_ArtistMetadata_ArtistMetadataId",
|
||||
table: "Tag",
|
||||
column: "ArtistMetadataId",
|
||||
principalTable: "ArtistMetadata",
|
||||
principalColumn: "Id");
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_Tag_EpisodeMetadata_EpisodeMetadataId",
|
||||
table: "Tag",
|
||||
column: "EpisodeMetadataId",
|
||||
principalTable: "EpisodeMetadata",
|
||||
principalColumn: "Id");
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_Tag_SeasonMetadata_SeasonMetadataId",
|
||||
table: "Tag",
|
||||
column: "SeasonMetadataId",
|
||||
principalTable: "SeasonMetadata",
|
||||
principalColumn: "Id");
|
||||
}
|
||||
}
|
||||
}
|
||||
4414
ErsatzTV.Infrastructure/Migrations/20230120152621_Add_AnchorSmartCollectionKey.Designer.cs
generated
Normal file
4414
ErsatzTV.Infrastructure/Migrations/20230120152621_Add_AnchorSmartCollectionKey.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,41 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ErsatzTV.Infrastructure.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddAnchorSmartCollectionKey : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_PlayoutProgramScheduleAnchor_SmartCollection_SmartCollectionId",
|
||||
table: "PlayoutProgramScheduleAnchor");
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_PlayoutProgramScheduleAnchor_SmartCollection_SmartCollectionId",
|
||||
table: "PlayoutProgramScheduleAnchor",
|
||||
column: "SmartCollectionId",
|
||||
principalTable: "SmartCollection",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_PlayoutProgramScheduleAnchor_SmartCollection_SmartCollectionId",
|
||||
table: "PlayoutProgramScheduleAnchor");
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_PlayoutProgramScheduleAnchor_SmartCollection_SmartCollectionId",
|
||||
table: "PlayoutProgramScheduleAnchor",
|
||||
column: "SmartCollectionId",
|
||||
principalTable: "SmartCollection",
|
||||
principalColumn: "Id");
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -184,9 +184,10 @@ public sealed class SearchIndex : ISearchIndex
|
||||
{ "searchField", searchField }
|
||||
};
|
||||
|
||||
client.Breadcrumbs.Leave("SearchIndex.Search", BreadcrumbType.State, metadata);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(searchQuery.Replace("*", string.Empty).Replace("?", string.Empty)))
|
||||
client?.Breadcrumbs?.Leave("SearchIndex.Search", BreadcrumbType.State, metadata);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(searchQuery.Replace("*", string.Empty).Replace("?", string.Empty)) ||
|
||||
_writer.MaxDoc == 0)
|
||||
{
|
||||
return new SearchResult(new List<SearchItem>(), 0);
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FluentAssertions" Version="6.8.0" />
|
||||
<PackageReference Include="FluentAssertions" Version="6.9.0" />
|
||||
<PackageReference Include="LanguageExt.Core" Version="4.4.0" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.4.1" />
|
||||
<PackageReference Include="Moq" Version="4.18.4" />
|
||||
|
||||
@@ -8,7 +8,8 @@ using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ErsatzTV.Scanner.Application.Jellyfin;
|
||||
|
||||
public class SynchronizeJellyfinLibraryByIdHandler : IRequestHandler<SynchronizeJellyfinLibraryById, Either<BaseError, string>>
|
||||
public class
|
||||
SynchronizeJellyfinLibraryByIdHandler : IRequestHandler<SynchronizeJellyfinLibraryById, Either<BaseError, string>>
|
||||
{
|
||||
private readonly IConfigElementRepository _configElementRepository;
|
||||
private readonly IJellyfinMovieLibraryScanner _jellyfinMovieLibraryScanner;
|
||||
@@ -62,10 +63,16 @@ public class SynchronizeJellyfinLibraryByIdHandler : IRequestHandler<Synchronize
|
||||
if (parameters.ForceScan || (parameters.LibraryRefreshInterval > 0 && nextScan < DateTimeOffset.Now))
|
||||
{
|
||||
// need the jellyfin admin user id for now
|
||||
await _mediator.Send(
|
||||
Either<BaseError, Unit> syncAdminResult = await _mediator.Send(
|
||||
new SynchronizeJellyfinAdminUserId(parameters.Library.MediaSourceId),
|
||||
cancellationToken);
|
||||
|
||||
|
||||
foreach (BaseError error in syncAdminResult.LeftToSeq())
|
||||
{
|
||||
_logger.LogError("Error synchronizing jellyfin admin user id: {Error}", error);
|
||||
return error;
|
||||
}
|
||||
|
||||
Either<BaseError, Unit> result = parameters.Library.MediaKind switch
|
||||
{
|
||||
LibraryMediaKind.Movies =>
|
||||
@@ -118,7 +125,7 @@ public class SynchronizeJellyfinLibraryByIdHandler : IRequestHandler<Synchronize
|
||||
}
|
||||
|
||||
_logger.LogDebug("Skipping unforced scan of jellyfin media library {Name}", parameters.Library.Name);
|
||||
|
||||
|
||||
// send an empty progress update for the library name
|
||||
await _mediator.Publish(
|
||||
new ScannerProgressUpdate(
|
||||
|
||||
@@ -287,7 +287,7 @@ public class LocalStatisticsProvider : ILocalStatisticsProvider
|
||||
startInfo.ArgumentList.Add("null");
|
||||
startInfo.ArgumentList.Add("-");
|
||||
|
||||
var probe = new Process
|
||||
using var probe = new Process
|
||||
{
|
||||
StartInfo = startInfo
|
||||
};
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
<TargetFramework>net7.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<Configurations>Debug;Release;Debug No Sync</Configurations>
|
||||
<Platforms>AnyCPU</Platforms>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.CommandLine;
|
||||
using System.Diagnostics;
|
||||
using ErsatzTV.Scanner.Application.Emby;
|
||||
using ErsatzTV.Scanner.Application.Jellyfin;
|
||||
using ErsatzTV.Scanner.Application.MediaSources;
|
||||
@@ -82,6 +83,8 @@ public class Worker : BackgroundService
|
||||
if (IsScanningEnabled())
|
||||
{
|
||||
bool force = context.ParseResult.GetValueForOption(forceOption);
|
||||
SetProcessPriority(force);
|
||||
|
||||
int libraryId = context.ParseResult.GetValueForArgument(libraryIdArgument);
|
||||
|
||||
using IServiceScope scope = _serviceScopeFactory.CreateScope();
|
||||
@@ -98,6 +101,8 @@ public class Worker : BackgroundService
|
||||
if (IsScanningEnabled())
|
||||
{
|
||||
bool force = context.ParseResult.GetValueForOption(forceOption);
|
||||
SetProcessPriority(force);
|
||||
|
||||
bool deep = context.ParseResult.GetValueForOption(deepOption);
|
||||
int libraryId = context.ParseResult.GetValueForArgument(libraryIdArgument);
|
||||
|
||||
@@ -115,6 +120,8 @@ public class Worker : BackgroundService
|
||||
if (IsScanningEnabled())
|
||||
{
|
||||
bool force = context.ParseResult.GetValueForOption(forceOption);
|
||||
SetProcessPriority(force);
|
||||
|
||||
int libraryId = context.ParseResult.GetValueForArgument(libraryIdArgument);
|
||||
|
||||
using IServiceScope scope = _serviceScopeFactory.CreateScope();
|
||||
@@ -131,6 +138,8 @@ public class Worker : BackgroundService
|
||||
if (IsScanningEnabled())
|
||||
{
|
||||
bool force = context.ParseResult.GetValueForOption(forceOption);
|
||||
SetProcessPriority(force);
|
||||
|
||||
int libraryId = context.ParseResult.GetValueForArgument(libraryIdArgument);
|
||||
|
||||
using IServiceScope scope = _serviceScopeFactory.CreateScope();
|
||||
@@ -150,18 +159,33 @@ public class Worker : BackgroundService
|
||||
return rootCommand;
|
||||
}
|
||||
|
||||
#if !DEBUG_NO_SYNC
|
||||
private bool IsScanningEnabled()
|
||||
{
|
||||
#if !DEBUG_NO_SYNC
|
||||
// don't want to flag the logger as unused (only used when sync is disabled)
|
||||
ILogger<Worker> _ = _logger;
|
||||
return true;
|
||||
}
|
||||
#else
|
||||
private bool IsScanningEnabled()
|
||||
{
|
||||
_logger.LogInformation("Scanning is disabled via DEBUG_NO_SYNC");
|
||||
return false;
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
private void SetProcessPriority(bool force)
|
||||
{
|
||||
if (force)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var process = Process.GetCurrentProcess();
|
||||
process.PriorityClass = ProcessPriorityClass.BelowNormal;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to set scanner priority");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,12 +77,12 @@ Global
|
||||
{591FB3F4-4DD8-441B-B7C8-F2A42BF69992}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{591FB3F4-4DD8-441B-B7C8-F2A42BF69992}.Debug No Sync|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{591FB3F4-4DD8-441B-B7C8-F2A42BF69992}.Debug No Sync|Any CPU.Build.0 = Debug|Any CPU
|
||||
{5664D574-2B8B-41C1-B091-8D3E887AE24E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{5664D574-2B8B-41C1-B091-8D3E887AE24E}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{5664D574-2B8B-41C1-B091-8D3E887AE24E}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{5664D574-2B8B-41C1-B091-8D3E887AE24E}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{5664D574-2B8B-41C1-B091-8D3E887AE24E}.Debug No Sync|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{5664D574-2B8B-41C1-B091-8D3E887AE24E}.Debug No Sync|Any CPU.Build.0 = Debug|Any CPU
|
||||
{5664D574-2B8B-41C1-B091-8D3E887AE24E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{5664D574-2B8B-41C1-B091-8D3E887AE24E}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{2EF80455-953D-4696-831D-E8CBCA82B0EF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{2EF80455-953D-4696-831D-E8CBCA82B0EF}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{2EF80455-953D-4696-831D-E8CBCA82B0EF}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
<Router AppAssembly="@typeof(Program).Assembly">
|
||||
<Found Context="routeData">
|
||||
<RouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)"/>
|
||||
</Found>
|
||||
<NotFound>
|
||||
<PageTitle>Not found</PageTitle>
|
||||
<LayoutView Layout="@typeof(MainLayout)">
|
||||
<p role="alert">Sorry, there's nothing at this address.</p>
|
||||
</LayoutView>
|
||||
</NotFound>
|
||||
</Router>
|
||||
<CascadingAuthenticationState>
|
||||
<Router AppAssembly="@typeof(Program).Assembly">
|
||||
<Found Context="routeData">
|
||||
<RouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)"/>
|
||||
</Found>
|
||||
<NotFound>
|
||||
<PageTitle>Not found</PageTitle>
|
||||
<LayoutView Layout="@typeof(MainLayout)">
|
||||
<p role="alert">Sorry, there's nothing at this address.</p>
|
||||
</LayoutView>
|
||||
</NotFound>
|
||||
</Router>
|
||||
</CascadingAuthenticationState>
|
||||
13
ErsatzTV/Controllers/AccountController.cs
Normal file
13
ErsatzTV/Controllers/AccountController.cs
Normal file
@@ -0,0 +1,13 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ErsatzTV.Controllers;
|
||||
|
||||
[ApiController]
|
||||
public class AccountController : ControllerBase
|
||||
{
|
||||
[HttpPost("account/logout")]
|
||||
public IActionResult Logout()
|
||||
{
|
||||
return new SignOutResult(new[] { "oidc", "cookie" });
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,13 @@
|
||||
using ErsatzTV.Application.Channels;
|
||||
using ErsatzTV.Core.Api.Channels;
|
||||
using ErsatzTV.Filters;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ErsatzTV.Controllers.Api;
|
||||
|
||||
[ApiController]
|
||||
[V2ApiActionFilter]
|
||||
public class ChannelController
|
||||
{
|
||||
private readonly IMediator _mediator;
|
||||
|
||||
@@ -2,12 +2,14 @@
|
||||
using ErsatzTV.Application.FFmpegProfiles;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Api.FFmpegProfiles;
|
||||
using ErsatzTV.Filters;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ErsatzTV.Controllers.Api;
|
||||
|
||||
[ApiController]
|
||||
[V2ApiActionFilter]
|
||||
public class FFmpegProfileController
|
||||
{
|
||||
private readonly IMediator _mediator;
|
||||
@@ -28,10 +30,10 @@ public class FFmpegProfileController
|
||||
[Required] [FromBody]
|
||||
UpdateFFmpegProfile request) => await _mediator.Send(request);
|
||||
|
||||
[HttpGet("/api/ffmpeg/profiles/{id}")]
|
||||
[HttpGet("/api/ffmpeg/profiles/{id:int}")]
|
||||
public async Task<Option<FFmpegFullProfileResponseModel>> GetOne(int id) =>
|
||||
await _mediator.Send(new GetFFmpegFullProfileByIdForApi(id));
|
||||
|
||||
[HttpDelete("/api/ffmpeg/delete/{id}")]
|
||||
[HttpDelete("/api/ffmpeg/delete/{id:int}")]
|
||||
public async Task DeleteProfileAsync(int id) => await _mediator.Send(new DeleteFFmpegProfile(id));
|
||||
}
|
||||
|
||||
20
ErsatzTV/Controllers/Api/MaintenanceController.cs
Normal file
20
ErsatzTV/Controllers/Api/MaintenanceController.cs
Normal file
@@ -0,0 +1,20 @@
|
||||
using ErsatzTV.Application.Maintenance;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ErsatzTV.Controllers.Api;
|
||||
|
||||
[ApiController]
|
||||
public class MaintenanceController
|
||||
{
|
||||
private readonly IMediator _mediator;
|
||||
|
||||
public MaintenanceController(IMediator mediator) => _mediator = mediator;
|
||||
|
||||
[HttpGet("/api/maintenance/gc")]
|
||||
public async Task<IActionResult> GarbageCollection([FromQuery] bool force = false)
|
||||
{
|
||||
await _mediator.Send(new ReleaseMemory(force));
|
||||
return new OkResult();
|
||||
}
|
||||
}
|
||||
@@ -145,6 +145,7 @@ public class ArtworkController : ControllerBase
|
||||
Right: async r =>
|
||||
{
|
||||
HttpClient client = _httpClientFactory.CreateClient();
|
||||
HttpContext.Response.RegisterForDispose(client);
|
||||
client.DefaultRequestHeaders.Add("X-Plex-Token", r.AuthToken);
|
||||
|
||||
var fullPath = new Uri(r.Uri, transcodePath);
|
||||
@@ -152,6 +153,8 @@ public class ArtworkController : ControllerBase
|
||||
fullPath,
|
||||
HttpCompletionOption.ResponseHeadersRead,
|
||||
cancellationToken);
|
||||
HttpContext.Response.RegisterForDispose(response);
|
||||
|
||||
Stream stream = await response.Content.ReadAsStreamAsync(cancellationToken);
|
||||
|
||||
return new FileStreamResult(
|
||||
@@ -170,12 +173,15 @@ public class ArtworkController : ControllerBase
|
||||
Right: async vm =>
|
||||
{
|
||||
HttpClient client = _httpClientFactory.CreateClient();
|
||||
HttpContext.Response.RegisterForDispose(client);
|
||||
|
||||
Url fullPath = JellyfinUrl.ForArtwork(vm.Address, path);
|
||||
HttpResponseMessage response = await client.GetAsync(
|
||||
fullPath,
|
||||
HttpCompletionOption.ResponseHeadersRead,
|
||||
cancellationToken);
|
||||
HttpContext.Response.RegisterForDispose(response);
|
||||
|
||||
Stream stream = await response.Content.ReadAsStreamAsync(cancellationToken);
|
||||
|
||||
return new FileStreamResult(
|
||||
@@ -194,12 +200,15 @@ public class ArtworkController : ControllerBase
|
||||
Right: async vm =>
|
||||
{
|
||||
HttpClient client = _httpClientFactory.CreateClient();
|
||||
HttpContext.Response.RegisterForDispose(client);
|
||||
|
||||
Url fullPath = EmbyUrl.ForArtwork(vm.Address, path);
|
||||
HttpResponseMessage response = await client.GetAsync(
|
||||
fullPath,
|
||||
HttpCompletionOption.ResponseHeadersRead,
|
||||
cancellationToken);
|
||||
HttpContext.Response.RegisterForDispose(response);
|
||||
|
||||
Stream stream = await response.Content.ReadAsStreamAsync(cancellationToken);
|
||||
|
||||
return new FileStreamResult(
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.Diagnostics;
|
||||
using CliWrap;
|
||||
using ErsatzTV.Application.Streaming;
|
||||
using ErsatzTV.Core.FFmpeg;
|
||||
using ErsatzTV.Extensions;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
@@ -47,7 +48,7 @@ public class InternalController : ControllerBase
|
||||
Command command = processModel.Process;
|
||||
|
||||
_logger.LogInformation("ffmpeg arguments {FFmpegArguments}", command.Arguments);
|
||||
var process = new Process
|
||||
var process = new FFmpegProcess
|
||||
{
|
||||
StartInfo = new ProcessStartInfo
|
||||
{
|
||||
@@ -59,6 +60,7 @@ public class InternalController : ControllerBase
|
||||
CreateNoWindow = true
|
||||
}
|
||||
};
|
||||
HttpContext.Response.RegisterForDispose(process);
|
||||
|
||||
foreach ((string key, string value) in command.EnvironmentVariables)
|
||||
{
|
||||
|
||||
@@ -97,7 +97,7 @@ public class IptvController : ControllerBase
|
||||
|
||||
_logger.LogInformation("Starting ts stream for channel {ChannelNumber}", channelNumber);
|
||||
_logger.LogInformation("ffmpeg arguments {FFmpegArguments}", command.Arguments);
|
||||
var process = new Process
|
||||
var process = new FFmpegProcess
|
||||
{
|
||||
StartInfo = new ProcessStartInfo
|
||||
{
|
||||
@@ -109,6 +109,7 @@ public class IptvController : ControllerBase
|
||||
CreateNoWindow = true
|
||||
}
|
||||
};
|
||||
HttpContext.Response.RegisterForDispose(process);
|
||||
|
||||
foreach ((string key, string value) in command.EnvironmentVariables)
|
||||
{
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
<IncludeAllContentForSelfExtract>true</IncludeAllContentForSelfExtract>
|
||||
<Configurations>Debug;Release;Debug No Sync</Configurations>
|
||||
<Platforms>AnyCPU</Platforms>
|
||||
<UserSecretsId>bf31217d-f4ec-4520-8cc3-138059044ede</UserSecretsId>
|
||||
</PropertyGroup>
|
||||
|
||||
<Target Name="PublishRunWebpack" AfterTargets="ComputeFilesToPublish">
|
||||
@@ -55,14 +56,15 @@
|
||||
<PackageReference Include="Bugsnag.AspNet.Core" Version="3.1.0" />
|
||||
<PackageReference Include="FluentValidation" Version="11.4.0" />
|
||||
<PackageReference Include="FluentValidation.AspNetCore" Version="11.2.2" />
|
||||
<PackageReference Include="HtmlSanitizer" Version="8.0.601" />
|
||||
<PackageReference Include="HtmlSanitizer" Version="8.0.645" />
|
||||
<PackageReference Include="LanguageExt.Core" Version="4.4.0" />
|
||||
<PackageReference Include="Markdig" Version="0.30.4" />
|
||||
<PackageReference Include="MediatR.Courier.DependencyInjection" Version="5.0.0" />
|
||||
<PackageReference Include="MediatR.Extensions.Microsoft.DependencyInjection" Version="11.0.0" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="7.0.1" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.SpaServices.Extensions" Version="7.0.1" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="7.0.1">
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="7.0.2" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="7.0.2" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.SpaServices.Extensions" Version="7.0.2" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="7.0.2">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
|
||||
18
ErsatzTV/Filters/V2ApiActionFilter.cs
Normal file
18
ErsatzTV/Filters/V2ApiActionFilter.cs
Normal file
@@ -0,0 +1,18 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.Filters;
|
||||
|
||||
namespace ErsatzTV.Filters;
|
||||
|
||||
public class V2ApiActionFilter : ActionFilterAttribute
|
||||
{
|
||||
private static readonly Lazy<bool> UseV2Ui =
|
||||
new(() => !string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("ETV_V2_UI")));
|
||||
|
||||
public override void OnActionExecuting(ActionExecutingContext context)
|
||||
{
|
||||
if (!UseV2Ui.Value)
|
||||
{
|
||||
context.Result = new NotFoundResult();
|
||||
}
|
||||
}
|
||||
}
|
||||
22
ErsatzTV/OidcHelper.cs
Normal file
22
ErsatzTV/OidcHelper.cs
Normal file
@@ -0,0 +1,22 @@
|
||||
namespace ErsatzTV;
|
||||
|
||||
public static class OidcHelper
|
||||
{
|
||||
public static void Init(IConfiguration configuration)
|
||||
{
|
||||
Authority = configuration["OIDC:Authority"];
|
||||
ClientId = configuration["OIDC:ClientId"];
|
||||
ClientSecret = configuration["OIDC:ClientSecret"];
|
||||
LogoutUri = configuration["OIDC:LogoutUri"];
|
||||
|
||||
IsEnabled = !string.IsNullOrWhiteSpace(Authority) &&
|
||||
!string.IsNullOrWhiteSpace(ClientId) &&
|
||||
!string.IsNullOrWhiteSpace(ClientSecret);
|
||||
}
|
||||
|
||||
public static string Authority { get; private set; }
|
||||
public static string ClientId { get; private set; }
|
||||
public static string ClientSecret { get; private set; }
|
||||
public static string LogoutUri { get; private set; }
|
||||
public static bool IsEnabled { get; private set; }
|
||||
}
|
||||
486
ErsatzTV/Pages/PlayoutAlternateSchedulesEditor.razor
Normal file
486
ErsatzTV/Pages/PlayoutAlternateSchedulesEditor.razor
Normal file
@@ -0,0 +1,486 @@
|
||||
@page "/playouts/{Id:int}/alternate-schedules"
|
||||
@using ErsatzTV.Application.ProgramSchedules
|
||||
@using ErsatzTV.Application.Playouts
|
||||
@using Microsoft.AspNetCore.Components
|
||||
@using ErsatzTV.Application.Channels
|
||||
@using System.Text
|
||||
@using System.Globalization
|
||||
@implements IDisposable
|
||||
@inject NavigationManager NavigationManager
|
||||
@inject ILogger<ScheduleItemsEditor> Logger
|
||||
@inject ISnackbar Snackbar
|
||||
@inject IMediator Mediator
|
||||
|
||||
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="pt-8">
|
||||
<MudTable Hover="true" Items="_items.OrderBy(i => i.Index)" Dense="true" @bind-SelectedItem="_selectedItem">
|
||||
<ToolBarContent>
|
||||
<MudText Typo="Typo.h6">@_channelName Alternate Schedules</MudText>
|
||||
<MudSpacer />
|
||||
<MudText Typo="Typo.subtitle1" Class="mr-3">In priority order from top to bottom</MudText>
|
||||
</ToolBarContent>
|
||||
<ColGroup>
|
||||
<col/>
|
||||
<col/>
|
||||
<col/>
|
||||
<col/>
|
||||
<col style="width: 60px;"/>
|
||||
<col style="width: 60px;"/>
|
||||
<col style="width: 60px;"/>
|
||||
</ColGroup>
|
||||
<HeaderContent>
|
||||
<MudTh>Schedule</MudTh>
|
||||
<MudTh>Days of the Week</MudTh>
|
||||
<MudTh>Days of the Month</MudTh>
|
||||
<MudTh>Months</MudTh>
|
||||
<MudTh/>
|
||||
<MudTh/>
|
||||
<MudTh/>
|
||||
</HeaderContent>
|
||||
<RowTemplate>
|
||||
<MudTd DataLabel="Schedule">
|
||||
<MudText Typo="@(context == _selectedItem ? Typo.subtitle2 : Typo.body2)">
|
||||
@context.ProgramSchedule.Name
|
||||
</MudText>
|
||||
</MudTd>
|
||||
<MudTd DataLabel="Days of the Week">
|
||||
<MudText Typo="@(context == _selectedItem ? Typo.subtitle2 : Typo.body2)">
|
||||
@ToDaysOfWeekString(context.DaysOfWeek)
|
||||
</MudText>
|
||||
</MudTd>
|
||||
<MudTd DataLabel="Days of the Month">
|
||||
<MudText Typo="@(context == _selectedItem ? Typo.subtitle2 : Typo.body2)">
|
||||
@ToDaysOfMonthString(context.DaysOfMonth)
|
||||
</MudText>
|
||||
</MudTd>
|
||||
<MudTd DataLabel="Months">
|
||||
<MudText Typo="@(context == _selectedItem ? Typo.subtitle2 : Typo.body2)">
|
||||
@ToMonthsOfYearString(context.MonthsOfYear)
|
||||
</MudText>
|
||||
</MudTd>
|
||||
<MudTd>
|
||||
<MudIconButton Icon="@Icons.Material.Filled.ArrowUpward"
|
||||
OnClick="@(_ => MoveItemUp(context))"
|
||||
Disabled="@(_items.All(x => x.Index >= context.Index))">
|
||||
</MudIconButton>
|
||||
</MudTd>
|
||||
<MudTd>
|
||||
<MudIconButton Icon="@Icons.Material.Filled.ArrowDownward"
|
||||
OnClick="@(_ => MoveItemDown(context))"
|
||||
Disabled="@(_items.All(x => x.Index <= context.Index))">
|
||||
</MudIconButton>
|
||||
</MudTd>
|
||||
<MudTd>
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Delete"
|
||||
OnClick="@(_ => RemoveAlternateSchedule(context))"
|
||||
Disabled="@(_items.Count == 1)">
|
||||
</MudIconButton>
|
||||
</MudTd>
|
||||
</RowTemplate>
|
||||
</MudTable>
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Default" OnClick="@(_ => AddAlternateSchedule())" Class="mt-4">
|
||||
Add Alternate Schedule
|
||||
</MudButton>
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="@(_ => SaveChanges())" Class="mt-4 ml-4">
|
||||
Save Changes
|
||||
</MudButton>
|
||||
|
||||
@if (_selectedItem is not null)
|
||||
{
|
||||
<EditForm Model="_selectedItem">
|
||||
<FluentValidator/>
|
||||
<div style="display: flex; flex-direction: row;" class="mt-6">
|
||||
<div style="flex-grow: 1; max-width: 400px;" class="mr-6">
|
||||
<MudCard>
|
||||
<MudCardContent>
|
||||
<MudSelect Label="Schedule" @bind-Value="_selectedItem.ProgramSchedule" For="@(() => _selectedItem.ProgramSchedule)">
|
||||
@foreach (ProgramScheduleViewModel schedule in _schedules)
|
||||
{
|
||||
<MudSelectItem Value="@schedule">@schedule.Name</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
</MudCardContent>
|
||||
</MudCard>
|
||||
<MudCard Class="mt-4">
|
||||
<MudCardContent>
|
||||
<MudElement HtmlTag="div" Class="mt-3">
|
||||
<MudCheckBox T="bool" Label="@_dtf.GetDayName(DayOfWeek.Monday)"
|
||||
Checked="@(_selectedItem.DaysOfWeek.Contains(DayOfWeek.Monday))"
|
||||
CheckedChanged="@(c => DayOfWeekChanged(DayOfWeek.Monday, c))"/>
|
||||
</MudElement>
|
||||
<MudElement HtmlTag="div" Class="mt-2">
|
||||
<MudCheckBox T="bool" Label="@_dtf.GetDayName(DayOfWeek.Tuesday)"
|
||||
Checked="@(_selectedItem.DaysOfWeek.Contains(DayOfWeek.Tuesday))"
|
||||
CheckedChanged="@(c => DayOfWeekChanged(DayOfWeek.Tuesday, c))"/>
|
||||
</MudElement>
|
||||
<MudElement HtmlTag="div" Class="mt-2">
|
||||
<MudCheckBox T="bool" Label="@_dtf.GetDayName(DayOfWeek.Wednesday)"
|
||||
Checked="@(_selectedItem.DaysOfWeek.Contains(DayOfWeek.Wednesday))"
|
||||
CheckedChanged="@(c => DayOfWeekChanged(DayOfWeek.Wednesday, c))"/>
|
||||
</MudElement>
|
||||
<MudElement HtmlTag="div" Class="mt-2">
|
||||
<MudCheckBox T="bool" Label="@_dtf.GetDayName(DayOfWeek.Thursday)"
|
||||
Checked="@(_selectedItem.DaysOfWeek.Contains(DayOfWeek.Thursday))"
|
||||
CheckedChanged="@(c => DayOfWeekChanged(DayOfWeek.Thursday, c))"/>
|
||||
</MudElement>
|
||||
<MudElement HtmlTag="div" Class="mt-2">
|
||||
<MudCheckBox T="bool" Label="@_dtf.GetDayName(DayOfWeek.Friday)"
|
||||
Checked="@(_selectedItem.DaysOfWeek.Contains(DayOfWeek.Friday))"
|
||||
CheckedChanged="@(c => DayOfWeekChanged(DayOfWeek.Friday, c))"/>
|
||||
</MudElement>
|
||||
<MudElement HtmlTag="div" Class="mt-2">
|
||||
<MudCheckBox T="bool" Label="@_dtf.GetDayName(DayOfWeek.Saturday)"
|
||||
Checked="@(_selectedItem.DaysOfWeek.Contains(DayOfWeek.Saturday))"
|
||||
CheckedChanged="@(c => DayOfWeekChanged(DayOfWeek.Saturday, c))"/>
|
||||
</MudElement>
|
||||
<MudElement HtmlTag="div" Class="mt-2">
|
||||
<MudCheckBox T="bool" Label="@_dtf.GetDayName(DayOfWeek.Sunday)"
|
||||
Checked="@(_selectedItem.DaysOfWeek.Contains(DayOfWeek.Sunday))"
|
||||
CheckedChanged="@(c => DayOfWeekChanged(DayOfWeek.Sunday, c))"/>
|
||||
</MudElement>
|
||||
</MudCardContent>
|
||||
<MudCardActions>
|
||||
<MudButton Variant="Variant.Text" Color="Color.Primary" OnClick="@(_ => SelectWeekdays())">
|
||||
Weekdays
|
||||
</MudButton>
|
||||
<MudButton Variant="Variant.Text" Color="Color.Primary" OnClick="@(_ => SelectWeekends())">
|
||||
Weekends
|
||||
</MudButton>
|
||||
<MudButton Variant="Variant.Text" Color="Color.Primary" OnClick="@(_ => SelectAllDaysOfWeek())">
|
||||
All
|
||||
</MudButton>
|
||||
<MudButton Variant="Variant.Text" Color="Color.Primary" OnClick="@(_ => SelectNoDaysOfWeek())">
|
||||
None
|
||||
</MudButton>
|
||||
</MudCardActions>
|
||||
</MudCard>
|
||||
</div>
|
||||
<div style="flex-grow: 1; max-width: 400px;" class="mr-6">
|
||||
<MudCard>
|
||||
<MudCardContent>
|
||||
<MudGrid Justify="Justify.FlexStart" Class="mt-3">
|
||||
@foreach (int day in Enumerable.Range(1, 31))
|
||||
{
|
||||
<MudItem xs="3">
|
||||
<MudCheckBox T="bool" Label="@day.ToString()"
|
||||
Checked="@(_selectedItem.DaysOfMonth.Contains(day))"
|
||||
CheckedChanged="@(c => DayOfMonthChanged(day, c))"/>
|
||||
</MudItem>
|
||||
}
|
||||
</MudGrid>
|
||||
</MudCardContent>
|
||||
<MudCardActions>
|
||||
<MudButton Variant="Variant.Text" Color="Color.Primary" OnClick="@(_ => SelectAllDaysOfMonth())">
|
||||
All
|
||||
</MudButton>
|
||||
<MudButton Variant="Variant.Text" Color="Color.Primary" OnClick="@(_ => SelectNoDaysOfMonth())">
|
||||
None
|
||||
</MudButton>
|
||||
</MudCardActions>
|
||||
</MudCard>
|
||||
</div>
|
||||
<div style="flex-grow: 1; max-width: 400px;">
|
||||
<MudCard>
|
||||
<MudCardContent>
|
||||
<MudElement HtmlTag="div" Class="mt-3">
|
||||
<MudCheckBox T="bool" Label="@_dtf.GetMonthName(1)"
|
||||
Checked="@(_selectedItem.MonthsOfYear.Contains(1))"
|
||||
CheckedChanged="@(c => MonthOfYearChanged(1, c))"/>
|
||||
</MudElement>
|
||||
@foreach (int month in Enumerable.Range(2, 11))
|
||||
{
|
||||
<MudElement HtmlTag="div" Class="mt-2">
|
||||
<MudCheckBox T="bool" Label="@_dtf.GetMonthName(month)"
|
||||
Checked="@(_selectedItem.MonthsOfYear.Contains(month))"
|
||||
CheckedChanged="@(c => MonthOfYearChanged(month, c))"/>
|
||||
</MudElement>
|
||||
}
|
||||
</MudCardContent>
|
||||
<MudCardActions>
|
||||
<MudButton Variant="Variant.Text" Color="Color.Primary" OnClick="@(_ => SelectAllMonthsOfYear())">
|
||||
All
|
||||
</MudButton>
|
||||
<MudButton Variant="Variant.Text" Color="Color.Primary" OnClick="@(_ => SelectNoMonthsOfYear())">
|
||||
None
|
||||
</MudButton>
|
||||
</MudCardActions>
|
||||
</MudCard>
|
||||
</div>
|
||||
</div>
|
||||
</EditForm>
|
||||
}
|
||||
</MudContainer>
|
||||
|
||||
@code {
|
||||
private readonly CancellationTokenSource _cts = new();
|
||||
|
||||
private readonly DateTimeFormatInfo _dtf = CultureInfo.CurrentUICulture.DateTimeFormat;
|
||||
|
||||
[Parameter]
|
||||
public int Id { get; set; }
|
||||
|
||||
private string _channelName;
|
||||
private List<PlayoutAlternateScheduleEditViewModel> _items;
|
||||
private List<ProgramScheduleViewModel> _schedules;
|
||||
|
||||
private PlayoutAlternateScheduleEditViewModel _selectedItem;
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_cts.Cancel();
|
||||
_cts.Dispose();
|
||||
}
|
||||
|
||||
protected override async Task OnParametersSetAsync() => await LoadScheduleItems();
|
||||
|
||||
private async Task LoadScheduleItems()
|
||||
{
|
||||
_schedules = await Mediator.Send(new GetAllProgramSchedules(), _cts.Token)
|
||||
.Map(list => list.OrderBy(vm => vm.Name, StringComparer.CurrentCultureIgnoreCase).ToList());
|
||||
|
||||
_channelName = (await Mediator.Send(new GetChannelNameByPlayoutId(Id), _cts.Token)).IfNone(string.Empty);
|
||||
|
||||
List<PlayoutAlternateScheduleViewModel> results = await Mediator.Send(new GetPlayoutAlternateSchedules(Id), _cts.Token);
|
||||
_items = results.Map(ProjectToEditViewModel).ToList();
|
||||
if (_items.Count == 1)
|
||||
{
|
||||
_selectedItem = _items.Head();
|
||||
}
|
||||
}
|
||||
|
||||
private PlayoutAlternateScheduleEditViewModel ProjectToEditViewModel(PlayoutAlternateScheduleViewModel item) =>
|
||||
new()
|
||||
{
|
||||
Id = item.Id,
|
||||
Index = item.Index,
|
||||
ProgramSchedule = _schedules.Find(vm => vm.Id == item.ProgramScheduleId),
|
||||
DaysOfWeek = item.DaysOfWeek.OrderBy(x => ((int)x + 6) % 7).ToList(),
|
||||
DaysOfMonth = item.DaysOfMonth.ToList(),
|
||||
MonthsOfYear = item.MonthsOfYear.ToList()
|
||||
};
|
||||
|
||||
private void DayOfWeekChanged(DayOfWeek dayOfWeek, bool isChecked)
|
||||
{
|
||||
if (isChecked && !_selectedItem.DaysOfWeek.Contains(dayOfWeek))
|
||||
{
|
||||
_selectedItem.DaysOfWeek.Add(dayOfWeek);
|
||||
_selectedItem.DaysOfWeek = _selectedItem.DaysOfWeek.OrderBy(x => ((int)x + 6) % 7).ToList();
|
||||
}
|
||||
|
||||
if (!isChecked)
|
||||
{
|
||||
_selectedItem.DaysOfWeek.Remove(dayOfWeek);
|
||||
}
|
||||
}
|
||||
|
||||
private void SelectWeekdays()
|
||||
{
|
||||
_selectedItem.DaysOfWeek.Clear();
|
||||
_selectedItem.DaysOfWeek.AddRange(new[]
|
||||
{
|
||||
DayOfWeek.Monday,
|
||||
DayOfWeek.Tuesday,
|
||||
DayOfWeek.Wednesday,
|
||||
DayOfWeek.Thursday,
|
||||
DayOfWeek.Friday
|
||||
});
|
||||
}
|
||||
|
||||
private void SelectWeekends()
|
||||
{
|
||||
_selectedItem.DaysOfWeek.Clear();
|
||||
_selectedItem.DaysOfWeek.AddRange(new[]
|
||||
{
|
||||
DayOfWeek.Saturday,
|
||||
DayOfWeek.Sunday
|
||||
});
|
||||
}
|
||||
|
||||
private void SelectAllDaysOfWeek()
|
||||
{
|
||||
_selectedItem.DaysOfWeek.Clear();
|
||||
_selectedItem.DaysOfWeek.AddRange(ProgramScheduleAlternate.AllDaysOfWeek());
|
||||
}
|
||||
|
||||
private void SelectNoDaysOfWeek()
|
||||
{
|
||||
_selectedItem.DaysOfWeek.Clear();
|
||||
}
|
||||
|
||||
private void DayOfMonthChanged(int dayOfMonth, bool isChecked)
|
||||
{
|
||||
if (isChecked && !_selectedItem.DaysOfMonth.Contains(dayOfMonth))
|
||||
{
|
||||
_selectedItem.DaysOfMonth.Add(dayOfMonth);
|
||||
_selectedItem.DaysOfMonth.Sort();
|
||||
}
|
||||
|
||||
if (!isChecked)
|
||||
{
|
||||
_selectedItem.DaysOfMonth.Remove(dayOfMonth);
|
||||
}
|
||||
}
|
||||
|
||||
private void SelectAllDaysOfMonth()
|
||||
{
|
||||
_selectedItem.DaysOfMonth.Clear();
|
||||
_selectedItem.DaysOfMonth.AddRange(ProgramScheduleAlternate.AllDaysOfMonth());
|
||||
}
|
||||
|
||||
private void SelectNoDaysOfMonth()
|
||||
{
|
||||
_selectedItem.DaysOfMonth.Clear();
|
||||
}
|
||||
|
||||
private void MonthOfYearChanged(int monthOfYear, bool isChecked)
|
||||
{
|
||||
if (isChecked && !_selectedItem.MonthsOfYear.Contains(monthOfYear))
|
||||
{
|
||||
_selectedItem.MonthsOfYear.Add(monthOfYear);
|
||||
_selectedItem.MonthsOfYear.Sort();
|
||||
}
|
||||
|
||||
if (!isChecked)
|
||||
{
|
||||
_selectedItem.MonthsOfYear.Remove(monthOfYear);
|
||||
}
|
||||
}
|
||||
|
||||
private void SelectAllMonthsOfYear()
|
||||
{
|
||||
_selectedItem.MonthsOfYear.Clear();
|
||||
_selectedItem.MonthsOfYear.AddRange(ProgramScheduleAlternate.AllMonthsOfYear());
|
||||
}
|
||||
|
||||
private void SelectNoMonthsOfYear()
|
||||
{
|
||||
_selectedItem.MonthsOfYear.Clear();
|
||||
}
|
||||
|
||||
private void AddAlternateSchedule()
|
||||
{
|
||||
var item = new PlayoutAlternateScheduleEditViewModel
|
||||
{
|
||||
Index = _items.Map(i => i.Index).DefaultIfEmpty().Max() + 1,
|
||||
ProgramSchedule = _schedules.Head(),
|
||||
DaysOfWeek = ProgramScheduleAlternate.AllDaysOfWeek(),
|
||||
DaysOfMonth = ProgramScheduleAlternate.AllDaysOfMonth(),
|
||||
MonthsOfYear = ProgramScheduleAlternate.AllMonthsOfYear()
|
||||
};
|
||||
|
||||
_items.Add(item);
|
||||
_selectedItem = item;
|
||||
}
|
||||
|
||||
private void RemoveAlternateSchedule(PlayoutAlternateScheduleEditViewModel item)
|
||||
{
|
||||
_selectedItem = null;
|
||||
_items.Remove(item);
|
||||
}
|
||||
|
||||
private void MoveItemUp(PlayoutAlternateScheduleEditViewModel item)
|
||||
{
|
||||
// swap with lower index
|
||||
PlayoutAlternateScheduleEditViewModel toSwap = _items.OrderByDescending(x => x.Index).First(x => x.Index < item.Index);
|
||||
(toSwap.Index, item.Index) = (item.Index, toSwap.Index);
|
||||
}
|
||||
|
||||
private void MoveItemDown(PlayoutAlternateScheduleEditViewModel item)
|
||||
{
|
||||
// swap with higher index
|
||||
PlayoutAlternateScheduleEditViewModel toSwap = _items.OrderBy(x => x.Index).First(x => x.Index > item.Index);
|
||||
(toSwap.Index, item.Index) = (item.Index, toSwap.Index);
|
||||
}
|
||||
|
||||
private async Task SaveChanges()
|
||||
{
|
||||
var items = _items.Map(item => new ReplacePlayoutAlternateSchedule(
|
||||
item.Id,
|
||||
item.Index,
|
||||
item.ProgramSchedule.Id,
|
||||
item.DaysOfWeek,
|
||||
item.DaysOfMonth,
|
||||
item.MonthsOfYear)).ToList();
|
||||
|
||||
Seq<BaseError> errorMessages = await Mediator.Send(new ReplacePlayoutAlternateScheduleItems(Id, items), _cts.Token)
|
||||
.Map(e => e.LeftToSeq());
|
||||
|
||||
errorMessages.HeadOrNone().Match(
|
||||
error =>
|
||||
{
|
||||
Snackbar.Add($"Unexpected error saving alternate schedules: {error.Value}", Severity.Error);
|
||||
Logger.LogError("Unexpected error saving alternate schedules: {Error}", error.Value);
|
||||
},
|
||||
() => NavigationManager.NavigateTo("/playouts"));
|
||||
}
|
||||
|
||||
private string ToDaysOfWeekString(List<DayOfWeek> daysOfWeek)
|
||||
{
|
||||
if (daysOfWeek.Count is 0 or 7)
|
||||
{
|
||||
return "*any*";
|
||||
}
|
||||
|
||||
daysOfWeek.Sort();
|
||||
|
||||
return string.Join(", ", daysOfWeek.Map(_dtf.GetAbbreviatedDayName));
|
||||
}
|
||||
|
||||
private string ToDaysOfMonthString(List<int> daysOfMonth)
|
||||
{
|
||||
if (daysOfMonth.Count is 0 or 31)
|
||||
{
|
||||
return "*any*";
|
||||
}
|
||||
|
||||
return ToRangeString(daysOfMonth);
|
||||
}
|
||||
|
||||
private string ToMonthsOfYearString(List<int> monthsOfYear)
|
||||
{
|
||||
if (monthsOfYear.Count is 0 or 12)
|
||||
{
|
||||
return "*any*";
|
||||
}
|
||||
|
||||
monthsOfYear.Sort();
|
||||
|
||||
return string.Join(", ", monthsOfYear.Map(_dtf.GetAbbreviatedMonthName));
|
||||
}
|
||||
|
||||
private static string ToRangeString(List<int> list)
|
||||
{
|
||||
list = list.Distinct().ToList();
|
||||
list.Sort();
|
||||
|
||||
var result = new StringBuilder();
|
||||
for (var i = 0; i < list.Count; i++)
|
||||
{
|
||||
int temp = list[i];
|
||||
|
||||
//add a number
|
||||
result.Append(list[i]);
|
||||
|
||||
//skip number(s) between a range
|
||||
while (i < list.Count - 1 && list[i + 1] == list[i] + 1)
|
||||
{
|
||||
i++;
|
||||
}
|
||||
|
||||
//add the range
|
||||
if (temp != list[i])
|
||||
{
|
||||
result.Append("-").Append(list[i]);
|
||||
}
|
||||
|
||||
//add comma
|
||||
if (i != list.Count - 1)
|
||||
{
|
||||
result.Append(", ");
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
return result.ToString();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -33,7 +33,7 @@
|
||||
</MudTh>
|
||||
<MudTh>
|
||||
<MudTableSortLabel SortBy="new Func<PlayoutViewModel, object>(x => x.ProgramSchedule.Name)">
|
||||
Schedule
|
||||
Default Schedule
|
||||
</MudTableSortLabel>
|
||||
</MudTh>
|
||||
@* <MudTh>Playout Type</MudTh> *@
|
||||
@@ -45,6 +45,11 @@
|
||||
@* <MudTd DataLabel="Playout Type">@context.ProgramSchedulePlayoutType</MudTd> *@
|
||||
<MudTd>
|
||||
<div style="align-items: center; display: flex;">
|
||||
<MudTooltip Text="Edit Alternate Schedules">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.EditCalendar"
|
||||
Link="@($"playouts/{context.PlayoutId}/alternate-schedules")">
|
||||
</MudIconButton>
|
||||
</MudTooltip>
|
||||
<MudTooltip Text="Reset Playout">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Refresh"
|
||||
OnClick="@(_ => ResetPlayout(context))">
|
||||
@@ -168,7 +173,7 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private async Task ResetPlayout(PlayoutNameViewModel playout)
|
||||
{
|
||||
await _mediator.Send(new BuildPlayout(playout.PlayoutId, PlayoutBuildMode.Reset), _cts.Token);
|
||||
|
||||
@@ -4,6 +4,7 @@ using ErsatzTV.Application;
|
||||
using ErsatzTV.Application.Emby;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Core.Interfaces.Locking;
|
||||
using MediatR;
|
||||
|
||||
@@ -135,10 +136,22 @@ public class EmbyService : BackgroundService
|
||||
Either<BaseError, string> result = await mediator.Send(request, cancellationToken);
|
||||
result.BiIter(
|
||||
name => _logger.LogDebug("Done synchronizing emby library {Name}", name),
|
||||
error => _logger.LogWarning(
|
||||
"Unable to synchronize emby library {LibraryId}: {Error}",
|
||||
request.EmbyLibraryId,
|
||||
error.Value));
|
||||
error =>
|
||||
{
|
||||
if (error is ScanIsNotRequired)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Scan is not required for emby library {LibraryId} at this time",
|
||||
request.EmbyLibraryId);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Unable to synchronize emby library {LibraryId}: {Error}",
|
||||
request.EmbyLibraryId,
|
||||
error.Value);
|
||||
}
|
||||
});
|
||||
|
||||
if (entityLocker.IsLibraryLocked(request.EmbyLibraryId))
|
||||
{
|
||||
|
||||
@@ -4,6 +4,7 @@ using ErsatzTV.Application;
|
||||
using ErsatzTV.Application.Jellyfin;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Core.Interfaces.Locking;
|
||||
using MediatR;
|
||||
|
||||
@@ -160,11 +161,23 @@ public class JellyfinService : BackgroundService
|
||||
Either<BaseError, string> result = await mediator.Send(request, cancellationToken);
|
||||
result.BiIter(
|
||||
name => _logger.LogDebug("Done synchronizing jellyfin library {Name}", name),
|
||||
error => _logger.LogWarning(
|
||||
"Unable to synchronize jellyfin library {LibraryId}: {Error}",
|
||||
request.JellyfinLibraryId,
|
||||
error.Value));
|
||||
|
||||
error =>
|
||||
{
|
||||
if (error is ScanIsNotRequired)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Scan is not required for jellyfin library {LibraryId} at this time",
|
||||
request.JellyfinLibraryId);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Unable to synchronize jellyfin library {LibraryId}: {Error}",
|
||||
request.JellyfinLibraryId,
|
||||
error.Value);
|
||||
}
|
||||
});
|
||||
|
||||
if (entityLocker.IsLibraryLocked(request.JellyfinLibraryId))
|
||||
{
|
||||
entityLocker.UnlockLibrary(request.JellyfinLibraryId);
|
||||
|
||||
@@ -4,6 +4,7 @@ using ErsatzTV.Application;
|
||||
using ErsatzTV.Application.Plex;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Core.Interfaces.Locking;
|
||||
using MediatR;
|
||||
|
||||
@@ -159,10 +160,22 @@ public class PlexService : BackgroundService
|
||||
Either<BaseError, string> result = await mediator.Send(request, cancellationToken);
|
||||
result.BiIter(
|
||||
name => _logger.LogDebug("Done synchronizing plex library {Name}", name),
|
||||
error => _logger.LogWarning(
|
||||
"Unable to synchronize plex library {LibraryId}: {Error}",
|
||||
request.PlexLibraryId,
|
||||
error.Value));
|
||||
error =>
|
||||
{
|
||||
if (error is ScanIsNotRequired)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Scan is not required for plex library {LibraryId} at this time",
|
||||
request.PlexLibraryId);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Unable to synchronize plex library {LibraryId}: {Error}",
|
||||
request.PlexLibraryId,
|
||||
error.Value);
|
||||
}
|
||||
});
|
||||
|
||||
if (entityLocker.IsLibraryLocked(request.PlexLibraryId))
|
||||
{
|
||||
|
||||
@@ -83,6 +83,11 @@ public class SchedulerService : BackgroundService
|
||||
// do other work every hour (on the hour)
|
||||
await DoWork(cancellationToken);
|
||||
}
|
||||
else if (roundedMinute % 30 == 0)
|
||||
{
|
||||
// release memory every 30 minutes no matter what
|
||||
await ReleaseMemory(cancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -100,6 +105,8 @@ public class SchedulerService : BackgroundService
|
||||
await ScanEmbyMediaSources(cancellationToken);
|
||||
#endif
|
||||
await MatchTraktLists(cancellationToken);
|
||||
|
||||
await ReleaseMemory(cancellationToken);
|
||||
}
|
||||
catch (Exception ex) when (ex is TaskCanceledException or OperationCanceledException)
|
||||
{
|
||||
@@ -270,4 +277,7 @@ public class SchedulerService : BackgroundService
|
||||
|
||||
private ValueTask DeleteOrphanedArtwork(CancellationToken cancellationToken) =>
|
||||
_workerChannel.WriteAsync(new DeleteOrphanedArtwork(), cancellationToken);
|
||||
|
||||
private ValueTask ReleaseMemory(CancellationToken cancellationToken) =>
|
||||
_workerChannel.WriteAsync(new ReleaseMemory(false), cancellationToken);
|
||||
}
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
using System.Threading.Channels;
|
||||
using Bugsnag;
|
||||
using ErsatzTV.Application;
|
||||
using ErsatzTV.Application.Subtitles;
|
||||
using MediatR;
|
||||
|
||||
namespace ErsatzTV.Services;
|
||||
|
||||
public class SubtitleWorkerService : BackgroundService
|
||||
{
|
||||
private readonly ChannelReader<ISubtitleWorkerRequest> _channel;
|
||||
private readonly ILogger<SubtitleWorkerService> _logger;
|
||||
private readonly IServiceScopeFactory _serviceScopeFactory;
|
||||
|
||||
public SubtitleWorkerService(
|
||||
ChannelReader<ISubtitleWorkerRequest> channel,
|
||||
IServiceScopeFactory serviceScopeFactory,
|
||||
ILogger<SubtitleWorkerService> logger)
|
||||
{
|
||||
_channel = channel;
|
||||
_serviceScopeFactory = serviceScopeFactory;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
_logger.LogInformation("Subtitle worker service started");
|
||||
|
||||
await foreach (ISubtitleWorkerRequest request in _channel.ReadAllAsync(cancellationToken))
|
||||
{
|
||||
using IServiceScope scope = _serviceScopeFactory.CreateScope();
|
||||
|
||||
try
|
||||
{
|
||||
switch (request)
|
||||
{
|
||||
case ExtractEmbeddedSubtitles extractEmbeddedSubtitles:
|
||||
IMediator mediator = scope.ServiceProvider.GetRequiredService<IMediator>();
|
||||
await mediator.Send(extractEmbeddedSubtitles, cancellationToken);
|
||||
break;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to handle subtitle worker request");
|
||||
|
||||
try
|
||||
{
|
||||
IClient client = scope.ServiceProvider.GetRequiredService<IClient>();
|
||||
client.Notify(ex);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// do nothing
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is TaskCanceledException or OperationCanceledException)
|
||||
{
|
||||
_logger.LogInformation("Subtitle worker service shutting down");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,9 @@ using ErsatzTV.Application.MediaCollections;
|
||||
using ErsatzTV.Application.MediaSources;
|
||||
using ErsatzTV.Application.Playouts;
|
||||
using ErsatzTV.Application.Search;
|
||||
using ErsatzTV.Application.Subtitles;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Core.Interfaces.Locking;
|
||||
using MediatR;
|
||||
|
||||
@@ -70,10 +72,22 @@ public class WorkerService : BackgroundService
|
||||
name => _logger.LogDebug(
|
||||
"Done scanning local library {Library}",
|
||||
name),
|
||||
error => _logger.LogWarning(
|
||||
"Unable to scan local library {LibraryId}: {Error}",
|
||||
scanLocalLibrary.LibraryId,
|
||||
error.Value));
|
||||
error =>
|
||||
{
|
||||
if (error is ScanIsNotRequired)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Scan is not required for local library {LibraryId} at this time",
|
||||
scanLocalLibrary.LibraryId);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Unable to scan local library {LibraryId}: {Error}",
|
||||
scanLocalLibrary.LibraryId,
|
||||
error.Value);
|
||||
}
|
||||
});
|
||||
|
||||
if (entityLocker.IsLibraryLocked(scanLocalLibrary.LibraryId))
|
||||
{
|
||||
@@ -96,6 +110,12 @@ public class WorkerService : BackgroundService
|
||||
case MatchTraktListItems matchTraktListItems:
|
||||
await mediator.Send(matchTraktListItems, cancellationToken);
|
||||
break;
|
||||
case ExtractEmbeddedSubtitles extractEmbeddedSubtitles:
|
||||
await mediator.Send(extractEmbeddedSubtitles, cancellationToken);
|
||||
break;
|
||||
case ReleaseMemory aggressivelyReleaseMemory:
|
||||
await mediator.Send(aggressivelyReleaseMemory, cancellationToken);
|
||||
break;
|
||||
}
|
||||
}
|
||||
catch (ObjectDisposedException) when (cancellationToken.IsCancellationRequested)
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
@using ErsatzTV.Extensions
|
||||
@using ErsatzTV.Application.Search
|
||||
@implements IDisposable
|
||||
@inject NavigationManager _navigationManager
|
||||
@inject IMediator _mediator
|
||||
@inject NavigationManager NavigationManager
|
||||
@inject IMediator Mediator
|
||||
|
||||
<MudThemeProvider Theme="_ersatzTvTheme"/>
|
||||
<MudDialogProvider DisableBackdropClick="true"/>
|
||||
@@ -17,49 +17,51 @@
|
||||
<img src="images/ersatztv.png" alt="ErsatzTV"/>
|
||||
</a>
|
||||
</div>
|
||||
<EditForm Model="@_dummyModel" OnSubmit="@(_ => PerformSearch())">
|
||||
<MudTextField T="string"
|
||||
@bind-Value="@Query"
|
||||
AdornmentIcon="@Icons.Material.Filled.Search"
|
||||
Adornment="Adornment.Start"
|
||||
Variant="Variant.Outlined"
|
||||
Immediate="true"
|
||||
Class="search-bar"
|
||||
@onclick="@(() => _isOpen = true)"
|
||||
OnKeyUp="OnKeyUp">
|
||||
</MudTextField>
|
||||
<MudPopover Open="@_isOpen" MaxHeight="300" AnchorOrigin="Origin.BottomCenter" TransformOrigin="Origin.TopCenter" RelativeWidth="true">
|
||||
@if (!string.IsNullOrWhiteSpace(_query) && _query.Length >= 3)
|
||||
{
|
||||
var matches = _searchTargets.Where(s => s.Name.Contains(_query, StringComparison.CurrentCultureIgnoreCase)).ToList();
|
||||
if (matches.Any())
|
||||
<div class="search-form">
|
||||
<EditForm Model="@_dummyModel" OnSubmit="@(_ => PerformSearch())">
|
||||
<MudTextField T="string"
|
||||
@bind-Value="@Query"
|
||||
AdornmentIcon="@Icons.Material.Filled.Search"
|
||||
Adornment="Adornment.Start"
|
||||
Variant="Variant.Outlined"
|
||||
Immediate="true"
|
||||
Class="search-bar"
|
||||
@onclick="@(() => _isOpen = true)"
|
||||
OnKeyUp="OnKeyUp">
|
||||
</MudTextField>
|
||||
<MudPopover Open="@_isOpen" MaxHeight="300" AnchorOrigin="Origin.BottomCenter" TransformOrigin="Origin.TopCenter" RelativeWidth="true">
|
||||
@if (!string.IsNullOrWhiteSpace(_query) && _query.Length >= 3)
|
||||
{
|
||||
<MudList Clickable="true" Dense="true">
|
||||
@foreach (SearchTargetViewModel searchTarget in matches)
|
||||
{
|
||||
<MudListItem @key="@searchTarget" OnClick="@(() => NavigateTo(searchTarget))">
|
||||
<MudText Typo="Typo.body1">@searchTarget.Name</MudText>
|
||||
<MudText Typo="Typo.subtitle1" Class="mud-text-disabled">
|
||||
@(searchTarget.Kind switch
|
||||
{
|
||||
SearchTargetKind.Channel => "Channel",
|
||||
SearchTargetKind.FFmpegProfile => "FFmpeg Profile",
|
||||
SearchTargetKind.ChannelWatermark => "Channel Watermark",
|
||||
SearchTargetKind.Collection => "Collection",
|
||||
SearchTargetKind.MultiCollection => "Multi Collection",
|
||||
SearchTargetKind.SmartCollection => "Smart Collection",
|
||||
SearchTargetKind.Schedule => "Schedule",
|
||||
SearchTargetKind.ScheduleItems => "Schedule Items",
|
||||
_ => string.Empty
|
||||
})
|
||||
</MudText>
|
||||
</MudListItem>
|
||||
}
|
||||
</MudList>
|
||||
var matches = _searchTargets.Where(s => s.Name.Contains(_query, StringComparison.CurrentCultureIgnoreCase)).ToList();
|
||||
if (matches.Any())
|
||||
{
|
||||
<MudList Clickable="true" Dense="true">
|
||||
@foreach (SearchTargetViewModel searchTarget in matches)
|
||||
{
|
||||
<MudListItem @key="@searchTarget" OnClick="@(() => NavigateTo(searchTarget))">
|
||||
<MudText Typo="Typo.body1">@searchTarget.Name</MudText>
|
||||
<MudText Typo="Typo.subtitle1" Class="mud-text-disabled">
|
||||
@(searchTarget.Kind switch
|
||||
{
|
||||
SearchTargetKind.Channel => "Channel",
|
||||
SearchTargetKind.FFmpegProfile => "FFmpeg Profile",
|
||||
SearchTargetKind.ChannelWatermark => "Channel Watermark",
|
||||
SearchTargetKind.Collection => "Collection",
|
||||
SearchTargetKind.MultiCollection => "Multi Collection",
|
||||
SearchTargetKind.SmartCollection => "Smart Collection",
|
||||
SearchTargetKind.Schedule => "Schedule",
|
||||
SearchTargetKind.ScheduleItems => "Schedule Items",
|
||||
_ => string.Empty
|
||||
})
|
||||
</MudText>
|
||||
</MudListItem>
|
||||
}
|
||||
</MudList>
|
||||
}
|
||||
}
|
||||
}
|
||||
</MudPopover>
|
||||
</EditForm>
|
||||
</MudPopover>
|
||||
</EditForm>
|
||||
</div>
|
||||
<MudSpacer/>
|
||||
<MudLink Color="Color.Info" Href="iptv/channels.m3u" Target="_blank" Underline="Underline.None">M3U</MudLink>
|
||||
<MudLink Color="Color.Info" Href="iptv/xmltv.xml" Target="_blank" Class="mx-4" Underline="Underline.None">XMLTV</MudLink>
|
||||
@@ -73,6 +75,13 @@
|
||||
<MudTooltip Text="GitHub">
|
||||
<MudIconButton Icon="@Icons.Custom.Brands.GitHub" Color="Color.Primary" Link="https://github.com/jasongdove/ErsatzTV" Target="_blank"/>
|
||||
</MudTooltip>
|
||||
<AuthorizeView>
|
||||
<form action="/account/logout" method="post">
|
||||
<MudTooltip Text="Logout">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Logout" Color="Color.Secondary" ButtonType="ButtonType.Submit"/>
|
||||
</MudTooltip>
|
||||
</form>
|
||||
</AuthorizeView>
|
||||
</MudAppBar>
|
||||
<MudDrawer Open="true" Elevation="2" ClipMode="DrawerClipMode.Always">
|
||||
<MudNavMenu>
|
||||
@@ -176,17 +185,17 @@
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
await base.OnParametersSetAsync();
|
||||
_query = _navigationManager.Uri.GetSearchQuery();
|
||||
_query = NavigationManager.Uri.GetSearchQuery();
|
||||
|
||||
if (_searchTargets is null)
|
||||
{
|
||||
_searchTargets = await _mediator.Send(new QuerySearchTargets(), _cts.Token);
|
||||
_searchTargets = await Mediator.Send(new QuerySearchTargets(), _cts.Token);
|
||||
}
|
||||
}
|
||||
|
||||
private void PerformSearch()
|
||||
{
|
||||
_navigationManager.NavigateTo(_query.GetRelativeSearchQuery(), true);
|
||||
NavigationManager.NavigateTo(_query.GetRelativeSearchQuery(), true);
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
@@ -206,7 +215,7 @@
|
||||
|
||||
private void NavigateTo(SearchTargetViewModel searchTarget) =>
|
||||
// need to force smart collections to navigate since the query string is all that differs
|
||||
_navigationManager.NavigateTo(UrlFor(searchTarget), searchTarget.Kind is SearchTargetKind.SmartCollection);
|
||||
NavigationManager.NavigateTo(UrlFor(searchTarget), searchTarget.Kind is SearchTargetKind.SmartCollection);
|
||||
|
||||
private string UrlFor(SearchTargetViewModel searchTarget) =>
|
||||
searchTarget.Kind switch
|
||||
|
||||
@@ -60,10 +60,13 @@ using FluentValidation.AspNetCore;
|
||||
using Ganss.Xss;
|
||||
using MediatR;
|
||||
using MediatR.Courier.DependencyInjection;
|
||||
using Microsoft.AspNetCore.Authentication.Cookies;
|
||||
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
|
||||
using Microsoft.AspNetCore.HttpOverrides;
|
||||
using Microsoft.AspNetCore.StaticFiles;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.FileProviders;
|
||||
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
|
||||
using Microsoft.IO;
|
||||
using MudBlazor.Services;
|
||||
using Newtonsoft.Json;
|
||||
@@ -118,6 +121,64 @@ public class Startup
|
||||
#endif
|
||||
});
|
||||
|
||||
OidcHelper.Init(Configuration);
|
||||
|
||||
if (OidcHelper.IsEnabled)
|
||||
{
|
||||
services.AddAuthentication(
|
||||
options =>
|
||||
{
|
||||
options.DefaultScheme = "cookie";
|
||||
options.DefaultChallengeScheme = "oidc";
|
||||
})
|
||||
.AddCookie(
|
||||
"cookie",
|
||||
options =>
|
||||
{
|
||||
options.CookieManager = new ChunkingCookieManager();
|
||||
|
||||
options.Cookie.HttpOnly = true;
|
||||
options.Cookie.SameSite = SameSiteMode.None;
|
||||
options.Cookie.SecurePolicy = CookieSecurePolicy.Always;
|
||||
})
|
||||
.AddOpenIdConnect(
|
||||
"oidc",
|
||||
options =>
|
||||
{
|
||||
options.Authority = OidcHelper.Authority;
|
||||
options.ClientId = OidcHelper.ClientId;
|
||||
options.ClientSecret = OidcHelper.ClientSecret;
|
||||
|
||||
options.ResponseType = OpenIdConnectResponseType.Code;
|
||||
options.UsePkce = true;
|
||||
options.ResponseMode = OpenIdConnectResponseMode.Query;
|
||||
|
||||
options.Scope.Clear();
|
||||
options.Scope.Add("openid");
|
||||
|
||||
options.CallbackPath = new PathString("/callback");
|
||||
|
||||
options.SaveTokens = true;
|
||||
|
||||
options.NonceCookie.SecurePolicy = CookieSecurePolicy.Always;
|
||||
options.CorrelationCookie.SecurePolicy = CookieSecurePolicy.Always;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(OidcHelper.LogoutUri))
|
||||
{
|
||||
options.Events = new OpenIdConnectEvents
|
||||
{
|
||||
OnRedirectToIdentityProviderForSignOut = context =>
|
||||
{
|
||||
context.Response.Redirect(OidcHelper.LogoutUri);
|
||||
context.HandleResponse();
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
services.AddCors(
|
||||
o => o.AddPolicy(
|
||||
"AllowAll",
|
||||
@@ -149,14 +210,23 @@ public class Startup
|
||||
services.AddFluentValidationAutoValidation();
|
||||
services.AddValidatorsFromAssemblyContaining<Startup>();
|
||||
|
||||
if (!CurrentEnvironment.IsDevelopment())
|
||||
string v2 = Environment.GetEnvironmentVariable("ETV_UI_V2");
|
||||
if (!CurrentEnvironment.IsDevelopment() && !string.IsNullOrWhiteSpace(v2))
|
||||
{
|
||||
services.AddSpaStaticFiles(options => options.RootPath = "wwwroot/v2");
|
||||
}
|
||||
|
||||
services.AddMemoryCache();
|
||||
|
||||
services.AddRazorPages();
|
||||
services.AddRazorPages(
|
||||
options =>
|
||||
{
|
||||
if (OidcHelper.IsEnabled)
|
||||
{
|
||||
options.Conventions.AuthorizeFolder("/");
|
||||
}
|
||||
});
|
||||
|
||||
services.AddServerSideBlazor();
|
||||
|
||||
services.AddMudServices();
|
||||
@@ -317,8 +387,15 @@ public class Startup
|
||||
});
|
||||
|
||||
app.UseRouting();
|
||||
|
||||
if (OidcHelper.IsEnabled)
|
||||
{
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
}
|
||||
|
||||
if (!env.IsDevelopment())
|
||||
string v2 = Environment.GetEnvironmentVariable("ETV_UI_V2");
|
||||
if (!env.IsDevelopment() && !string.IsNullOrWhiteSpace(v2))
|
||||
{
|
||||
app.Map(
|
||||
"/v2",
|
||||
@@ -330,6 +407,13 @@ public class Startup
|
||||
}
|
||||
|
||||
app2.UseRouting();
|
||||
|
||||
if (OidcHelper.IsEnabled)
|
||||
{
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
}
|
||||
|
||||
app2.UseEndpoints(e => e.MapFallbackToFile("index.html"));
|
||||
app2.UseFileServer(
|
||||
new FileServerOptions
|
||||
@@ -375,7 +459,6 @@ public class Startup
|
||||
AddChannel<IJellyfinBackgroundServiceRequest>(services);
|
||||
AddChannel<IEmbyBackgroundServiceRequest>(services);
|
||||
AddChannel<IFFmpegWorkerRequest>(services);
|
||||
AddChannel<ISubtitleWorkerRequest>(services);
|
||||
AddChannel<ISearchIndexBackgroundServiceRequest>(services);
|
||||
|
||||
services.AddScoped<IFFmpegVersionHealthCheck, FFmpegVersionHealthCheck>();
|
||||
@@ -470,7 +553,6 @@ public class Startup
|
||||
services.AddHostedService<EmbyService>();
|
||||
services.AddHostedService<JellyfinService>();
|
||||
services.AddHostedService<PlexService>();
|
||||
services.AddHostedService<SubtitleWorkerService>();
|
||||
#endif
|
||||
services.AddHostedService<FFmpegLocatorService>();
|
||||
services.AddHostedService<WorkerService>();
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
using ErsatzTV.ViewModels;
|
||||
using FluentValidation;
|
||||
|
||||
namespace ErsatzTV.Validators;
|
||||
|
||||
public class PlayoutAlternateScheduleEditViewModelValidator : AbstractValidator<PlayoutAlternateScheduleEditViewModel>
|
||||
{
|
||||
public PlayoutAlternateScheduleEditViewModelValidator()
|
||||
{
|
||||
RuleFor(p => p.ProgramSchedule).NotNull();
|
||||
}
|
||||
}
|
||||
13
ErsatzTV/ViewModels/PlayoutAlternateScheduleEditViewModel.cs
Normal file
13
ErsatzTV/ViewModels/PlayoutAlternateScheduleEditViewModel.cs
Normal file
@@ -0,0 +1,13 @@
|
||||
using ErsatzTV.Application.ProgramSchedules;
|
||||
|
||||
namespace ErsatzTV.ViewModels;
|
||||
|
||||
public class PlayoutAlternateScheduleEditViewModel
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public int Index { get; set; }
|
||||
public ProgramScheduleViewModel ProgramSchedule { get; set; }
|
||||
public List<DayOfWeek> DaysOfWeek { get; set; }
|
||||
public List<int> DaysOfMonth { get; set; }
|
||||
public List<int> MonthsOfYear { get; set; }
|
||||
}
|
||||
@@ -74,7 +74,7 @@
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.app-bar form { flex-grow: 1; }
|
||||
.app-bar .search-form { flex-grow: 1; }
|
||||
|
||||
.fanart-container {
|
||||
position: relative;
|
||||
|
||||
@@ -40,6 +40,8 @@ Create a Schedule by navigating to the `Schedules` page, clicking `Add Schedule`
|
||||
|
||||
* `Keep Multi-Part Episodes Together`: This only applies to shuffled schedule items, and will try to intelligently group multi-part episodes (i.e. `s05e02 - whatever part 1` and `s05e03 - whatever part 2`) so they are always scheduled together and always play in the correct order.
|
||||
* `Treat Collections As Shows`: This only applies when `Keep Multi-Part Episodes Together` is enabled, and will try to group multi-part episodes across shows within the collection (i.e. crossover episodes like `Show 1 - s03e04 - Whatever Part 1` and `Show 2 - s01e07 - Whatever Part 2`).
|
||||
* `Shuffle Schedule Items`: This shuffles the order of schedule items when building a playout, and is mostly used on channels with a mix of shows that require unique schedule item settings per show. Note that this disables fixed start times and flood mode.
|
||||
* `Random Start Point`: This starts each schedule item at a random place within the collection.
|
||||
|
||||
### Schedule Items
|
||||
|
||||
@@ -77,6 +79,7 @@ Select the desired playback order for media items within the selected collection
|
||||
- `Chronological`: Items are ordered by release date, then by season and episode number.
|
||||
- `Random`: Items are randomly ordered and may contain repeats.
|
||||
- `Shuffle`: Items are randomly ordered and no item will be played a second time until every item from the collection has been played once.
|
||||
- `Shuffle In Order`: Items are grouped (episodes by show, music videos by artist, one group for all movies), the group contents are sorted chronologically, and the groups are shuffled together while maintaining their individual chronological ordering.
|
||||
|
||||
#### Playout Mode
|
||||
|
||||
|
||||
@@ -19,4 +19,5 @@ cp -R "$REPO_ROOT/ErsatzTV-macOS/build/Release/ErsatzTV-macOS.app" "$APP_NAME"
|
||||
cp -a "$PUBLISH_OUTPUT_DIRECTORY" "$APP_NAME/Contents/MacOS"
|
||||
|
||||
chmod +x "$APP_NAME/Contents/MacOS/ErsatzTV"
|
||||
chmod +x "$APP_NAME/Contents/MacOS/ErsatzTV.Scanner"
|
||||
chmod +x "$APP_NAME/Contents/MacOS/ErsatzTV-macOS"
|
||||
|
||||
Reference in New Issue
Block a user