Compare commits

...

26 Commits

Author SHA1 Message Date
Jason Dove
3d821043bb update changelog for v0.7.3-beta [no ci] 2023-01-25 12:01:38 -06:00
Jason Dove
e69c58e615 conditionally disable v2 apis (#1135)
* conditionally disable v2 apis

* update changelog
2023-01-25 11:37:43 -06:00
Jason Dove
a21b6f9f4e add oidc logout url to support auth0 (#1134) 2023-01-25 09:30:36 -06:00
Jason Dove
99b8038852 add oidc support (#1133) 2023-01-25 08:37:59 -06:00
Jason Dove
ef8ca9f8c6 build mac artifacts on macos 11 (#1132) 2023-01-24 15:04:55 -06:00
Jason Dove
d9186df157 minor logging and doc updates (#1130) 2023-01-23 05:28:17 -06:00
Jason Dove
aca6bfb0bb fix multiple gcs after extracting subtitles (#1129) 2023-01-22 13:10:13 -06:00
Jason Dove
587fc3a98f release memory after extracting embedded subtitles (#1128) 2023-01-22 12:34:42 -06:00
Jason Dove
ab1c67e60e memory improvements (#1127)
* regularly release memory

* don't aggressively GC while legacy streaming

* update changelog
2023-01-22 09:16:24 -06:00
Jason Dove
e271f43066 more scan check fixes (#1126) 2023-01-21 08:22:56 -06:00
Jason Dove
6bf8feb26e fix local library scan check with new install (#1125) 2023-01-21 08:10:42 -06:00
Jason Dove
ffd66f6a21 fix removing media server libraries (#1124) 2023-01-20 09:31:18 -06:00
Jason Dove
3b135df4c1 scan with below-normal priority when unforced (#1123) 2023-01-20 06:05:39 -06:00
Jason Dove
4369d04940 scanner improvements (#1122)
* optimize periodic scanning

* set scanner process priority

* update dependencies
2023-01-20 05:37:39 -06:00
Jason Dove
faaa78fed7 update changelog [no ci] 2023-01-18 15:40:00 -06:00
Jason Dove
6bea1660ea disable mac compression; this is needed until dotnet 7.0.3 (#1120) 2023-01-18 15:13:07 -06:00
Jason Dove
8d46676c25 try to fix mac scanning (#1119) 2023-01-18 14:43:26 -06:00
Jason Dove
4c75e638a2 fix bug with smart collection progress (#1118) 2023-01-18 14:09:54 -06:00
Jason Dove
dd73a3803a fix schedule editor crash (#1115)
* fix schedule editor crash due to bad music video artist data

* update dependencies
2023-01-15 06:35:51 -06:00
Jason Dove
f6c345d7cf fix build 2023-01-10 15:13:42 -06:00
Jason Dove
585b56a668 bug fixes (#1107)
* don't search an empty search index

* fix bug with flood filler prediction check

* extract subtitles on primary worker thread
2023-01-10 14:45:04 -06:00
Jason Dove
f18f3b4f35 try to fix develop artifacts 2023-01-09 08:46:55 -06:00
Jason Dove
eb7871a048 fix alternate schedule playout update check (#1106)
* fix alternate schedule playout update check

* Revert "use mknejp/delete-release-assets again"

This reverts commit 07ac833067.
2023-01-09 05:36:15 -06:00
Jason Dove
000fc78fd3 add alternate schedule system (#1105)
* start to add program schedule alternates

* edit days of the week

* editor improvements

* save changes

* build playouts using alternate schedules

* reset playout as needed

* add priority message
2023-01-08 23:22:17 -06:00
Jason Dove
ba676ef956 add jellyfin admin error logging (#1102) 2023-01-07 09:55:02 -06:00
Jason Dove
36ea88e2d6 fix error display (#1099)
* fix error display by ignoring hw accel setting

* update changelog

* revert background change
2023-01-05 20:02:48 -06:00
100 changed files with 16553 additions and 724 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,3 @@
namespace ErsatzTV.Application.Channels;
public record GetChannelNameByPlayoutId(int PlayoutId) : IRequest<Option<string>>;

View File

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

View File

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

View File

@@ -52,7 +52,7 @@ public class UpdateFFmpegSettingsHandler : IRequestHandler<UpdateFFmpegSettings,
UseShellExecute = false
};
var test = new Process
using var test = new Process
{
StartInfo = startInfo
};

View File

@@ -1,5 +0,0 @@
namespace ErsatzTV.Application;
public interface ISubtitleWorkerRequest
{
}

View File

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

View File

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

View File

@@ -0,0 +1,6 @@
namespace ErsatzTV.Application.Maintenance;
public record ReleaseMemory(bool ForceAggressive) : IRequest<Unit>, IBackgroundServiceRequest
{
public DateTimeOffset RequestTime = DateTimeOffset.Now;
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,6 @@
using ErsatzTV.Core;
namespace ErsatzTV.Application.Playouts;
public record ReplacePlayoutAlternateScheduleItems
(int PlayoutId, List<ReplacePlayoutAlternateSchedule> Items) : IRequest<Either<BaseError, Unit>>;

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,3 @@
namespace ErsatzTV.Application.Playouts;
public record GetPlayoutAlternateSchedules(int PlayoutId) : IRequest<List<PlayoutAlternateScheduleViewModel>>;

View File

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

View File

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

View File

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

View File

@@ -3,4 +3,4 @@
namespace ErsatzTV.Application.Subtitles;
public record ExtractEmbeddedSubtitles(Option<int> PlayoutId) : IRequest<Either<BaseError, Unit>>,
ISubtitleWorkerRequest;
IBackgroundServiceRequest;

View File

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

View File

@@ -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" />

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

@@ -0,0 +1,8 @@
namespace ErsatzTV.Core.Errors;
public class ScanIsNotRequired : BaseError
{
public ScanIsNotRequired() : base("Scan is not required")
{
}
}

View File

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

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

View File

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

View File

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

View File

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

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

View File

@@ -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" />

View File

@@ -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" />

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

File diff suppressed because it is too large Load Diff

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@@ -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" />

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,12 @@
using ErsatzTV.ViewModels;
using FluentValidation;
namespace ErsatzTV.Validators;
public class PlayoutAlternateScheduleEditViewModelValidator : AbstractValidator<PlayoutAlternateScheduleEditViewModel>
{
public PlayoutAlternateScheduleEditViewModelValidator()
{
RuleFor(p => p.ProgramSchedule).NotNull();
}
}

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

View File

@@ -74,7 +74,7 @@
border-radius: 4px;
}
.app-bar form { flex-grow: 1; }
.app-bar .search-form { flex-grow: 1; }
.fanart-container {
position: relative;

View File

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

View File

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