Browse Source

update dependencies; code cleanup (#1357)

* update dependencies

* code cleanup
pull/1358/head
Jason Dove 3 years ago committed by GitHub
parent
commit
f929dc92d1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 2
      .config/dotnet-tools.json
  2. 1
      ErsatzTV.Application/Channels/Commands/RefreshChannelListHandler.cs
  3. 7
      ErsatzTV.Application/Playouts/Commands/BuildPlayoutHandler.cs
  4. 4
      ErsatzTV.Application/ProgramSchedules/Commands/ProgramScheduleItemCommandBase.cs
  5. 2
      ErsatzTV.Application/ProgramSchedules/Commands/ReplaceProgramScheduleItemsHandler.cs
  6. 4
      ErsatzTV.Application/Resolutions/Commands/CreateCustomResolutionHandler.cs
  7. 2
      ErsatzTV.Application/Resolutions/Commands/DeleteCustomResolutionHandler.cs
  8. 4
      ErsatzTV.Application/Search/Commands/RebuildSearchIndexHandler.cs
  9. 2
      ErsatzTV.Application/Streaming/Queries/GetPlayoutItemProcessByChannelNumberHandler.cs
  10. 12
      ErsatzTV.Application/Subtitles/Commands/ExtractEmbeddedSubtitlesHandler.cs
  11. 2
      ErsatzTV.Core.Tests/ErsatzTV.Core.Tests.csproj
  12. 10
      ErsatzTV.Core.Tests/Scheduling/PlayoutModeSchedulerBaseTests.cs
  13. 2
      ErsatzTV.Core/Domain/Resolution.cs
  14. 10
      ErsatzTV.Core/FFmpeg/FFmpegLibraryProcessService.cs
  15. 4
      ErsatzTV.Core/Interfaces/Scheduling/IMediaCollectionEnumerator.cs
  16. 6
      ErsatzTV.Core/Scheduling/ChronologicalMediaCollectionEnumerator.cs
  17. 8
      ErsatzTV.Core/Scheduling/CustomOrderCollectionEnumerator.cs
  18. 4
      ErsatzTV.Core/Scheduling/PlayoutModeSchedulerBase.cs
  19. 10
      ErsatzTV.Core/Scheduling/PlayoutModeSchedulerDuration.cs
  20. 2
      ErsatzTV.Core/Scheduling/PlayoutModeSchedulerFlood.cs
  21. 2
      ErsatzTV.Core/Scheduling/PlayoutModeSchedulerMultiple.cs
  22. 2
      ErsatzTV.Core/Scheduling/PlayoutModeSchedulerOne.cs
  23. 8
      ErsatzTV.Core/Scheduling/RandomizedMediaCollectionEnumerator.cs
  24. 8
      ErsatzTV.Core/Scheduling/SeasonEpisodeMediaCollectionEnumerator.cs
  25. 10
      ErsatzTV.Core/Scheduling/ShuffleInOrderCollectionEnumerator.cs
  26. 10
      ErsatzTV.Core/Scheduling/ShuffledMediaCollectionEnumerator.cs
  27. 10
      ErsatzTV.Core/SystemStartup.cs
  28. 4
      ErsatzTV.FFmpeg.Tests/Capabilities/Vaapi/VaapiCapabilityParserTests.cs
  29. 2
      ErsatzTV.FFmpeg.Tests/ErsatzTV.FFmpeg.Tests.csproj
  30. 2
      ErsatzTV.FFmpeg/Capabilities/DefaultHardwareCapabilities.cs
  31. 2
      ErsatzTV.FFmpeg/Capabilities/NoHardwareCapabilities.cs
  32. 6
      ErsatzTV.FFmpeg/Capabilities/NvidiaHardwareCapabilities.cs
  33. 2
      ErsatzTV.FFmpeg/Capabilities/Vaapi/VaapiCapabilityParser.cs
  34. 5
      ErsatzTV.FFmpeg/Capabilities/Vaapi/VaapiProfileEntrypoint.cs
  35. 220
      ErsatzTV.FFmpeg/Capabilities/VaapiHardwareCapabilities.cs
  36. 8
      ErsatzTV.FFmpeg/Encoder/Vaapi/EncoderH264Vaapi.cs
  37. 4
      ErsatzTV.FFmpeg/Encoder/Vaapi/EncoderHevcVaapi.cs
  38. 6
      ErsatzTV.FFmpeg/Encoder/Vaapi/EncoderMpeg2Vaapi.cs
  39. 5
      ErsatzTV.FFmpeg/Filter/ComplexFilter.cs
  40. 3
      ErsatzTV.FFmpeg/Option/Mp4OutputOptions.cs
  41. 2
      ErsatzTV.FFmpeg/Pipeline/PipelineBuilderBase.cs
  42. 2
      ErsatzTV.FFmpeg/Pipeline/QsvPipelineBuilder.cs
  43. 4
      ErsatzTV.FFmpeg/Pipeline/VaapiPipelineBuilder.cs
  44. 2
      ErsatzTV.Infrastructure.Tests/ErsatzTV.Infrastructure.Tests.csproj
  45. 2
      ErsatzTV.Infrastructure/ErsatzTV.Infrastructure.csproj
  46. 2
      ErsatzTV.Infrastructure/Locking/EntityLocker.cs
  47. 10
      ErsatzTV.Infrastructure/Scheduling/MultiEpisodeShuffleCollectionEnumerator.cs
  48. 6
      ErsatzTV.Scanner.Tests/Core/FFmpeg/TranscodingTests.cs
  49. 2
      ErsatzTV.Scanner.Tests/ErsatzTV.Scanner.Tests.csproj
  50. 2
      ErsatzTV/Controllers/InternalController.cs
  51. 6
      ErsatzTV/ErsatzTV.csproj
  52. 10
      ErsatzTV/Pages/Index.razor
  53. 2
      ErsatzTV/Pages/Playouts.razor
  54. 486
      ErsatzTV/Pages/ScheduleItemsEditor.razor
  55. 424
      ErsatzTV/Pages/Search.razor
  56. 458
      ErsatzTV/Pages/Settings.razor
  57. 440
      ErsatzTV/Pages/Trash.razor
  58. 5
      ErsatzTV/Program.cs
  59. 2
      ErsatzTV/Services/EmbyService.cs
  60. 2
      ErsatzTV/Services/FFmpegWorkerService.cs
  61. 2
      ErsatzTV/Services/RunOnce/CacheCleanerService.cs
  62. 2
      ErsatzTV/Services/RunOnce/DatabaseMigratorService.cs
  63. 2
      ErsatzTV/Services/ScannerService.cs
  64. 4
      ErsatzTV/Services/SchedulerService.cs
  65. 2
      ErsatzTV/Services/SearchIndexService.cs
  66. 2
      ErsatzTV/Services/WorkerService.cs
  67. 15
      ErsatzTV/Shared/AddCustomResolutionDialog.razor
  68. 5
      ErsatzTV/Shared/MainLayout.razor
  69. 5
      ErsatzTV/ViewModels/ProgramScheduleItemEditViewModel.cs

2
.config/dotnet-tools.json

@ -3,7 +3,7 @@
"isRoot": true, "isRoot": true,
"tools": { "tools": {
"jetbrains.resharper.globaltools": { "jetbrains.resharper.globaltools": {
"version": "2023.1.3", "version": "2023.2.0",
"commands": [ "commands": [
"jb" "jb"
] ]

1
ErsatzTV.Application/Channels/Commands/RefreshChannelListHandler.cs

@ -1,4 +1,3 @@
using System.Data;
using System.Data.Common; using System.Data.Common;
using System.Xml; using System.Xml;
using Dapper; using Dapper;

7
ErsatzTV.Application/Playouts/Commands/BuildPlayoutHandler.cs

@ -19,8 +19,8 @@ public class BuildPlayoutHandler : IRequestHandler<BuildPlayout, Either<BaseErro
{ {
private readonly IClient _client; private readonly IClient _client;
private readonly IDbContextFactory<TvContext> _dbContextFactory; private readonly IDbContextFactory<TvContext> _dbContextFactory;
private readonly IFFmpegSegmenterService _ffmpegSegmenterService;
private readonly IEntityLocker _entityLocker; private readonly IEntityLocker _entityLocker;
private readonly IFFmpegSegmenterService _ffmpegSegmenterService;
private readonly IPlayoutBuilder _playoutBuilder; private readonly IPlayoutBuilder _playoutBuilder;
private readonly ChannelWriter<IBackgroundServiceRequest> _workerChannel; private readonly ChannelWriter<IBackgroundServiceRequest> _workerChannel;
@ -112,7 +112,8 @@ public class BuildPlayoutHandler : IRequestHandler<BuildPlayout, Either<BaseErro
private static Validation<BaseError, Playout> DiscardAttemptsMustBeValid(Playout playout) private static Validation<BaseError, Playout> DiscardAttemptsMustBeValid(Playout playout)
{ {
foreach (ProgramScheduleItemDuration item in playout.ProgramSchedule.Items.OfType<ProgramScheduleItemDuration>()) foreach (ProgramScheduleItemDuration item in
playout.ProgramSchedule.Items.OfType<ProgramScheduleItemDuration>())
{ {
item.DiscardToFillAttempts = item.PlaybackOrder switch item.DiscardToFillAttempts = item.PlaybackOrder switch
{ {
@ -120,7 +121,7 @@ public class BuildPlayoutHandler : IRequestHandler<BuildPlayout, Either<BaseErro
_ => 0 _ => 0
}; };
} }
return playout; return playout;
} }

4
ErsatzTV.Application/ProgramSchedules/Commands/ProgramScheduleItemCommandBase.cs

@ -253,7 +253,9 @@ public abstract class ProgramScheduleItemCommandBase
PlaybackOrder = item.PlaybackOrder, PlaybackOrder = item.PlaybackOrder,
PlayoutDuration = FixDuration(item.PlayoutDuration.GetValueOrDefault()), PlayoutDuration = FixDuration(item.PlayoutDuration.GetValueOrDefault()),
TailMode = item.TailMode, TailMode = item.TailMode,
DiscardToFillAttempts = FixDiscardToFillAttempts(item.PlaybackOrder, item.DiscardToFillAttempts.GetValueOrDefault()), DiscardToFillAttempts = FixDiscardToFillAttempts(
item.PlaybackOrder,
item.DiscardToFillAttempts.GetValueOrDefault()),
CustomTitle = item.CustomTitle, CustomTitle = item.CustomTitle,
GuideMode = item.GuideMode, GuideMode = item.GuideMode,
PreRollFillerId = item.PreRollFillerId, PreRollFillerId = item.PreRollFillerId,

2
ErsatzTV.Application/ProgramSchedules/Commands/ReplaceProgramScheduleItemsHandler.cs

@ -29,7 +29,7 @@ public class ReplaceProgramScheduleItemsHandler : ProgramScheduleItemCommandBase
{ {
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, ProgramSchedule> validation = await Validate(dbContext, request); Validation<BaseError, ProgramSchedule> validation = await Validate(dbContext, request);
return await validation.Apply(ps => PersistItems(dbContext, request, ps)); return await LanguageExtensions.Apply(validation, ps => PersistItems(dbContext, request, ps));
} }
private async Task<IEnumerable<ProgramScheduleItemViewModel>> PersistItems( private async Task<IEnumerable<ProgramScheduleItemViewModel>> PersistItems(

4
ErsatzTV.Application/Resolutions/Commands/CreateCustomResolutionHandler.cs

@ -9,10 +9,8 @@ public class CreateCustomResolutionHandler : IRequestHandler<CreateCustomResolut
{ {
private readonly IDbContextFactory<TvContext> _dbContextFactory; private readonly IDbContextFactory<TvContext> _dbContextFactory;
public CreateCustomResolutionHandler(IDbContextFactory<TvContext> dbContextFactory) public CreateCustomResolutionHandler(IDbContextFactory<TvContext> dbContextFactory) =>
{
_dbContextFactory = dbContextFactory; _dbContextFactory = dbContextFactory;
}
public async Task<Option<BaseError>> Handle(CreateCustomResolution request, CancellationToken cancellationToken) public async Task<Option<BaseError>> Handle(CreateCustomResolution request, CancellationToken cancellationToken)
{ {

2
ErsatzTV.Application/Resolutions/Commands/DeleteCustomResolutionHandler.cs

@ -28,7 +28,7 @@ public class DeleteCustomResolutionHandler : IRequestHandler<DeleteCustomResolut
await dbContext.Connection.ExecuteAsync( await dbContext.Connection.ExecuteAsync(
@"UPDATE FFmpegProfile SET ResolutionId = 3 WHERE ResolutionId = @ResolutionId", @"UPDATE FFmpegProfile SET ResolutionId = 3 WHERE ResolutionId = @ResolutionId",
new { request.ResolutionId }); new { request.ResolutionId });
dbContext.Resolutions.Remove(resolution); dbContext.Resolutions.Remove(resolution);
await dbContext.SaveChangesAsync(cancellationToken); await dbContext.SaveChangesAsync(cancellationToken);
} }

4
ErsatzTV.Application/Search/Commands/RebuildSearchIndexHandler.cs

@ -14,11 +14,11 @@ public class RebuildSearchIndexHandler : IRequestHandler<RebuildSearchIndex>
{ {
private readonly IConfigElementRepository _configElementRepository; private readonly IConfigElementRepository _configElementRepository;
private readonly IFallbackMetadataProvider _fallbackMetadataProvider; private readonly IFallbackMetadataProvider _fallbackMetadataProvider;
private readonly SystemStartup _systemStartup;
private readonly ILocalFileSystem _localFileSystem; private readonly ILocalFileSystem _localFileSystem;
private readonly ILogger<RebuildSearchIndexHandler> _logger; private readonly ILogger<RebuildSearchIndexHandler> _logger;
private readonly ISearchIndex _searchIndex; private readonly ISearchIndex _searchIndex;
private readonly ICachingSearchRepository _searchRepository; private readonly ICachingSearchRepository _searchRepository;
private readonly SystemStartup _systemStartup;
public RebuildSearchIndexHandler( public RebuildSearchIndexHandler(
ISearchIndex searchIndex, ISearchIndex searchIndex,
@ -66,7 +66,7 @@ public class RebuildSearchIndexHandler : IRequestHandler<RebuildSearchIndex>
{ {
_logger.LogInformation("Search index is already version {Version}", _searchIndex.Version); _logger.LogInformation("Search index is already version {Version}", _searchIndex.Version);
} }
_systemStartup.SearchIndexIsReady(); _systemStartup.SearchIndexIsReady();
} }
} }

2
ErsatzTV.Application/Streaming/Queries/GetPlayoutItemProcessByChannelNumberHandler.cs

@ -462,7 +462,7 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
"Attempting to stream Plex file {PlexFileName} using key {PlexKey}", "Attempting to stream Plex file {PlexFileName} using key {PlexKey}",
pmf.Path, pmf.Path,
pmf.Key); pmf.Key);
return new PlayoutItemWithPath( return new PlayoutItemWithPath(
playoutItem, playoutItem,
$"http://localhost:{Settings.ListenPort}/media/plex/{plexMediaSourceId}/{pmf.Key}"); $"http://localhost:{Settings.ListenPort}/media/plex/{plexMediaSourceId}/{pmf.Key}");

12
ErsatzTV.Application/Subtitles/Commands/ExtractEmbeddedSubtitlesHandler.cs

@ -21,8 +21,8 @@ namespace ErsatzTV.Application.Subtitles;
public class ExtractEmbeddedSubtitlesHandler : IRequestHandler<ExtractEmbeddedSubtitles, Either<BaseError, Unit>> public class ExtractEmbeddedSubtitlesHandler : IRequestHandler<ExtractEmbeddedSubtitles, Either<BaseError, Unit>>
{ {
private readonly IDbContextFactory<TvContext> _dbContextFactory; private readonly IDbContextFactory<TvContext> _dbContextFactory;
private readonly ILocalFileSystem _localFileSystem;
private readonly IEntityLocker _entityLocker; private readonly IEntityLocker _entityLocker;
private readonly ILocalFileSystem _localFileSystem;
private readonly ILogger<ExtractEmbeddedSubtitlesHandler> _logger; private readonly ILogger<ExtractEmbeddedSubtitlesHandler> _logger;
private readonly ChannelWriter<IBackgroundServiceRequest> _workerChannel; private readonly ChannelWriter<IBackgroundServiceRequest> _workerChannel;
@ -74,7 +74,8 @@ public class ExtractEmbeddedSubtitlesHandler : IRequestHandler<ExtractEmbeddedSu
.AsNoTracking() .AsNoTracking()
.Filter( .Filter(
p => p.Channel.SubtitleMode != ChannelSubtitleMode.None || p => p.Channel.SubtitleMode != ChannelSubtitleMode.None ||
p.ProgramSchedule.Items.Any(psi => psi.SubtitleMode != null && psi.SubtitleMode != ChannelSubtitleMode.None)) p.ProgramSchedule.Items.Any(
psi => psi.SubtitleMode != null && psi.SubtitleMode != ChannelSubtitleMode.None))
.SelectOneAsync(p => p.Id, p => p.Id == request.PlayoutId.IfNone(-1)); .SelectOneAsync(p => p.Id, p => p.Id == request.PlayoutId.IfNone(-1));
playoutIdsToCheck.AddRange(requestedPlayout.Map(p => p.Id)); playoutIdsToCheck.AddRange(requestedPlayout.Map(p => p.Id));
@ -86,7 +87,8 @@ public class ExtractEmbeddedSubtitlesHandler : IRequestHandler<ExtractEmbeddedSu
.AsNoTracking() .AsNoTracking()
.Filter( .Filter(
p => p.Channel.SubtitleMode != ChannelSubtitleMode.None || p => p.Channel.SubtitleMode != ChannelSubtitleMode.None ||
p.ProgramSchedule.Items.Any(psi => psi.SubtitleMode != null && psi.SubtitleMode != ChannelSubtitleMode.None)) p.ProgramSchedule.Items.Any(
psi => psi.SubtitleMode != null && psi.SubtitleMode != ChannelSubtitleMode.None))
.Map(p => p.Id) .Map(p => p.Id)
.ToList(); .ToList();
} }
@ -109,7 +111,7 @@ public class ExtractEmbeddedSubtitlesHandler : IRequestHandler<ExtractEmbeddedSu
{ {
_entityLocker.LockPlayout(playoutId); _entityLocker.LockPlayout(playoutId);
} }
_logger.LogDebug("Checking playouts {PlayoutIds} for text subtitles to extract", playoutIdsToCheck); _logger.LogDebug("Checking playouts {PlayoutIds} for text subtitles to extract", playoutIdsToCheck);
// find all playout items in the next hour // find all playout items in the next hour
@ -167,7 +169,7 @@ public class ExtractEmbeddedSubtitlesHandler : IRequestHandler<ExtractEmbeddedSu
{ {
_entityLocker.UnlockPlayout(playoutId); _entityLocker.UnlockPlayout(playoutId);
} }
return Unit.Default; return Unit.Default;
} }
catch (Exception ex) when (ex is TaskCanceledException or OperationCanceledException) catch (Exception ex) when (ex is TaskCanceledException or OperationCanceledException)

2
ErsatzTV.Core.Tests/ErsatzTV.Core.Tests.csproj

@ -16,7 +16,7 @@
<PackageReference Include="Microsoft.Extensions.Logging" Version="7.0.0" /> <PackageReference Include="Microsoft.Extensions.Logging" Version="7.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="7.0.1" /> <PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="7.0.1" />
<PackageReference Include="Microsoft.Extensions.Logging.Debug" Version="7.0.0" /> <PackageReference Include="Microsoft.Extensions.Logging.Debug" Version="7.0.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.6.3" /> <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.7.0" />
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="17.6.40"> <PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="17.6.40">
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>

10
ErsatzTV.Core.Tests/Scheduling/PlayoutModeSchedulerBaseTests.cs

@ -58,7 +58,7 @@ public class PlayoutModeSchedulerBaseTests : SchedulerTestBase
scheduleItem, scheduleItem,
new PlayoutItem(), new PlayoutItem(),
new List<MediaChapter>(), new List<MediaChapter>(),
log: true, true,
_cancellationToken); _cancellationToken);
playoutItems.Count.Should().Be(1); playoutItems.Count.Should().Be(1);
@ -111,7 +111,7 @@ public class PlayoutModeSchedulerBaseTests : SchedulerTestBase
scheduleItem, scheduleItem,
new PlayoutItem(), new PlayoutItem(),
new List<MediaChapter> { new() }, new List<MediaChapter> { new() },
log: true, true,
_cancellationToken); _cancellationToken);
playoutItems.Count.Should().Be(1); playoutItems.Count.Should().Be(1);
@ -178,7 +178,7 @@ public class PlayoutModeSchedulerBaseTests : SchedulerTestBase
new() { StartTime = TimeSpan.Zero, EndTime = TimeSpan.FromMinutes(6) }, new() { StartTime = TimeSpan.Zero, EndTime = TimeSpan.FromMinutes(6) },
new() { StartTime = TimeSpan.FromMinutes(6), EndTime = TimeSpan.FromMinutes(60) } new() { StartTime = TimeSpan.FromMinutes(6), EndTime = TimeSpan.FromMinutes(60) }
}, },
log: true, true,
_cancellationToken); _cancellationToken);
playoutItems.Count.Should().Be(3); playoutItems.Count.Should().Be(3);
@ -270,7 +270,7 @@ public class PlayoutModeSchedulerBaseTests : SchedulerTestBase
new() { StartTime = TimeSpan.Zero, EndTime = TimeSpan.FromMinutes(6) }, new() { StartTime = TimeSpan.Zero, EndTime = TimeSpan.FromMinutes(6) },
new() { StartTime = TimeSpan.FromMinutes(6), EndTime = TimeSpan.FromMinutes(45) } new() { StartTime = TimeSpan.FromMinutes(6), EndTime = TimeSpan.FromMinutes(45) }
}, },
log: true, true,
_cancellationToken); _cancellationToken);
playoutItems.Count.Should().Be(5); playoutItems.Count.Should().Be(5);
@ -376,7 +376,7 @@ public class PlayoutModeSchedulerBaseTests : SchedulerTestBase
new() { StartTime = TimeSpan.Zero, EndTime = TimeSpan.FromMinutes(6) }, new() { StartTime = TimeSpan.Zero, EndTime = TimeSpan.FromMinutes(6) },
new() { StartTime = TimeSpan.FromMinutes(6), EndTime = TimeSpan.FromMinutes(45) } new() { StartTime = TimeSpan.FromMinutes(6), EndTime = TimeSpan.FromMinutes(45) }
}, },
log: true, true,
_cancellationToken); _cancellationToken);
playoutItems.Count.Should().Be(5); playoutItems.Count.Should().Be(5);

2
ErsatzTV.Core/Domain/Resolution.cs

@ -6,9 +6,9 @@ public class Resolution : IDisplaySize
{ {
public int Id { get; set; } public int Id { get; set; }
public string Name { get; set; } public string Name { get; set; }
public bool IsCustom { get; set; }
public int Height { get; set; } public int Height { get; set; }
public int Width { get; set; } public int Width { get; set; }
public bool IsCustom { get; set; }
public override string ToString() => $"{Width}x{Height}"; public override string ToString() => $"{Width}x{Height}";
} }

10
ErsatzTV.Core/FFmpeg/FFmpegLibraryProcessService.cs

@ -16,11 +16,11 @@ namespace ErsatzTV.Core.FFmpeg;
public class FFmpegLibraryProcessService : IFFmpegProcessService public class FFmpegLibraryProcessService : IFFmpegProcessService
{ {
private readonly IConfigElementRepository _configElementRepository;
private readonly FFmpegProcessService _ffmpegProcessService; private readonly FFmpegProcessService _ffmpegProcessService;
private readonly IFFmpegStreamSelector _ffmpegStreamSelector; private readonly IFFmpegStreamSelector _ffmpegStreamSelector;
private readonly ILogger<FFmpegLibraryProcessService> _logger; private readonly ILogger<FFmpegLibraryProcessService> _logger;
private readonly IPipelineBuilderFactory _pipelineBuilderFactory; private readonly IPipelineBuilderFactory _pipelineBuilderFactory;
private readonly IConfigElementRepository _configElementRepository;
private readonly FFmpegPlaybackSettingsCalculator _playbackSettingsCalculator; private readonly FFmpegPlaybackSettingsCalculator _playbackSettingsCalculator;
private readonly ITempFilePool _tempFilePool; private readonly ITempFilePool _tempFilePool;
@ -95,8 +95,8 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
hlsRealtime, hlsRealtime,
targetFramerate); targetFramerate);
var allSubtitles = await getSubtitles(playbackSettings); List<Subtitle> allSubtitles = await getSubtitles(playbackSettings);
Option<Subtitle> maybeSubtitle = Option<Subtitle> maybeSubtitle =
await _ffmpegStreamSelector.SelectSubtitleStream( await _ffmpegStreamSelector.SelectSubtitleStream(
allSubtitles, allSubtitles,
@ -208,7 +208,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
{ {
// use mpeg-ts by default // use mpeg-ts by default
outputFormat = OutputFormatKind.MpegTs; outputFormat = OutputFormatKind.MpegTs;
// override with setting if applicable // override with setting if applicable
Option<OutputFormatKind> maybeOutputFormat = await _configElementRepository Option<OutputFormatKind> maybeOutputFormat = await _configElementRepository
.GetValue<OutputFormatKind>(ConfigElementKey.FFmpegHlsDirectOutputFormat); .GetValue<OutputFormatKind>(ConfigElementKey.FFmpegHlsDirectOutputFormat);
@ -269,7 +269,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
{ {
return None; return None;
} }
// hls direct won't use extracted embedded subtitles // hls direct won't use extracted embedded subtitles
if (subtitle.SubtitleKind == SubtitleKind.Embedded) if (subtitle.SubtitleKind == SubtitleKind.Embedded)
{ {

4
ErsatzTV.Core/Interfaces/Scheduling/IMediaCollectionEnumerator.cs

@ -4,10 +4,10 @@ namespace ErsatzTV.Core.Interfaces.Scheduling;
public interface IMediaCollectionEnumerator public interface IMediaCollectionEnumerator
{ {
void ResetState(CollectionEnumeratorState state);
CollectionEnumeratorState State { get; } CollectionEnumeratorState State { get; }
Option<MediaItem> Current { get; } Option<MediaItem> Current { get; }
void MoveNext();
int Count { get; } int Count { get; }
Option<TimeSpan> MinimumDuration { get; } Option<TimeSpan> MinimumDuration { get; }
void ResetState(CollectionEnumeratorState state);
void MoveNext();
} }

6
ErsatzTV.Core/Scheduling/ChronologicalMediaCollectionEnumerator.cs

@ -6,8 +6,8 @@ namespace ErsatzTV.Core.Scheduling;
public sealed class ChronologicalMediaCollectionEnumerator : IMediaCollectionEnumerator public sealed class ChronologicalMediaCollectionEnumerator : IMediaCollectionEnumerator
{ {
private readonly IList<MediaItem> _sortedMediaItems;
private readonly Lazy<Option<TimeSpan>> _lazyMinimumDuration; private readonly Lazy<Option<TimeSpan>> _lazyMinimumDuration;
private readonly IList<MediaItem> _sortedMediaItems;
public ChronologicalMediaCollectionEnumerator( public ChronologicalMediaCollectionEnumerator(
IEnumerable<MediaItem> mediaItems, IEnumerable<MediaItem> mediaItems,
@ -31,11 +31,9 @@ public sealed class ChronologicalMediaCollectionEnumerator : IMediaCollectionEnu
} }
} }
public void ResetState(CollectionEnumeratorState state) public void ResetState(CollectionEnumeratorState state) =>
{
// seed doesn't matter in chronological // seed doesn't matter in chronological
State.Index = state.Index; State.Index = state.Index;
}
public CollectionEnumeratorState State { get; } public CollectionEnumeratorState State { get; }

8
ErsatzTV.Core/Scheduling/CustomOrderCollectionEnumerator.cs

@ -7,10 +7,10 @@ namespace ErsatzTV.Core.Scheduling;
public class CustomOrderCollectionEnumerator : IMediaCollectionEnumerator public class CustomOrderCollectionEnumerator : IMediaCollectionEnumerator
{ {
private readonly Collection _collection; private readonly Collection _collection;
private readonly Lazy<Option<TimeSpan>> _lazyMinimumDuration;
private readonly IList<MediaItem> _mediaItems; private readonly IList<MediaItem> _mediaItems;
private readonly IList<MediaItem> _sortedMediaItems; private readonly IList<MediaItem> _sortedMediaItems;
private readonly Lazy<Option<TimeSpan>> _lazyMinimumDuration;
public CustomOrderCollectionEnumerator( public CustomOrderCollectionEnumerator(
Collection collection, Collection collection,
@ -35,11 +35,9 @@ public class CustomOrderCollectionEnumerator : IMediaCollectionEnumerator
} }
} }
public void ResetState(CollectionEnumeratorState state) public void ResetState(CollectionEnumeratorState state) =>
{
// seed doesn't matter here // seed doesn't matter here
State.Index = state.Index; State.Index = state.Index;
}
public CollectionEnumeratorState State { get; } public CollectionEnumeratorState State { get; }
@ -48,6 +46,6 @@ public class CustomOrderCollectionEnumerator : IMediaCollectionEnumerator
public void MoveNext() => State.Index = (State.Index + 1) % _sortedMediaItems.Count; public void MoveNext() => State.Index = (State.Index + 1) % _sortedMediaItems.Count;
public Option<TimeSpan> MinimumDuration => _lazyMinimumDuration.Value; public Option<TimeSpan> MinimumDuration => _lazyMinimumDuration.Value;
public int Count => _sortedMediaItems.Count; public int Count => _sortedMediaItems.Count;
} }

4
ErsatzTV.Core/Scheduling/PlayoutModeSchedulerBase.cs

@ -4,8 +4,6 @@ using ErsatzTV.Core.Extensions;
using ErsatzTV.Core.Interfaces.Scheduling; using ErsatzTV.Core.Interfaces.Scheduling;
using LanguageExt.UnsafeValueAccess; using LanguageExt.UnsafeValueAccess;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Serilog;
using ILogger = Microsoft.Extensions.Logging.ILogger;
namespace ErsatzTV.Core.Scheduling; namespace ErsatzTV.Core.Scheduling;
@ -606,7 +604,7 @@ public abstract class PlayoutModeSchedulerBase<T> : IPlayoutModeScheduler<T> whe
} }
} }
} }
return result; return result;
} }

10
ErsatzTV.Core/Scheduling/PlayoutModeSchedulerDuration.cs

@ -26,7 +26,7 @@ public class PlayoutModeSchedulerDuration : PlayoutModeSchedulerBase<ProgramSche
var willFinishInTime = true; var willFinishInTime = true;
Option<DateTimeOffset> durationUntil = None; Option<DateTimeOffset> durationUntil = None;
int discardAttempts = 0; var discardAttempts = 0;
IMediaCollectionEnumerator contentEnumerator = IMediaCollectionEnumerator contentEnumerator =
collectionEnumerators[CollectionKey.ForScheduleItem(scheduleItem)]; collectionEnumerators[CollectionKey.ForScheduleItem(scheduleItem)];
@ -121,7 +121,7 @@ public class PlayoutModeSchedulerDuration : PlayoutModeSchedulerBase<ProgramSche
durationUntil.Do(du => playoutItem.GuideFinish = du.UtcDateTime); durationUntil.Do(du => playoutItem.GuideFinish = du.UtcDateTime);
DateTimeOffset durationFinish = nextState.DurationFinish.IfNone(SystemTime.MaxValueUtc); DateTimeOffset durationFinish = nextState.DurationFinish.IfNone(SystemTime.MaxValueUtc);
var enumeratorStates = new Dictionary<CollectionKey, CollectionEnumeratorState>(); var enumeratorStates = new Dictionary<CollectionKey, CollectionEnumeratorState>();
foreach ((CollectionKey key, IMediaCollectionEnumerator enumerator) in collectionEnumerators) foreach ((CollectionKey key, IMediaCollectionEnumerator enumerator) in collectionEnumerators)
{ {
@ -134,9 +134,9 @@ public class PlayoutModeSchedulerDuration : PlayoutModeSchedulerBase<ProgramSche
scheduleItem, scheduleItem,
playoutItem, playoutItem,
itemChapters, itemChapters,
log: false, false,
cancellationToken); cancellationToken);
DateTimeOffset itemEndTimeWithFiller = maybePlayoutItems.Max(pi => pi.FinishOffset); DateTimeOffset itemEndTimeWithFiller = maybePlayoutItems.Max(pi => pi.FinishOffset);
willFinishInTime = itemStartTime > durationFinish || willFinishInTime = itemStartTime > durationFinish ||
@ -165,7 +165,7 @@ public class PlayoutModeSchedulerDuration : PlayoutModeSchedulerBase<ProgramSche
{ {
enumerator.ResetState(enumeratorStates[key]); enumerator.ResetState(enumeratorStates[key]);
} }
TimeSpan durationBlock = itemEndTimeWithFiller - itemStartTime; TimeSpan durationBlock = itemEndTimeWithFiller - itemStartTime;
if (itemEndTimeWithFiller - itemStartTime > scheduleItem.PlayoutDuration) if (itemEndTimeWithFiller - itemStartTime > scheduleItem.PlayoutDuration)
{ {

2
ErsatzTV.Core/Scheduling/PlayoutModeSchedulerFlood.cs

@ -86,7 +86,7 @@ public class PlayoutModeSchedulerFlood : PlayoutModeSchedulerBase<ProgramSchedul
scheduleItem, scheduleItem,
playoutItem, playoutItem,
itemChapters, itemChapters,
log: false, false,
cancellationToken); cancellationToken);
DateTimeOffset itemEndTimeWithFiller = maybePlayoutItems.Max(pi => pi.FinishOffset); DateTimeOffset itemEndTimeWithFiller = maybePlayoutItems.Max(pi => pi.FinishOffset);

2
ErsatzTV.Core/Scheduling/PlayoutModeSchedulerMultiple.cs

@ -85,7 +85,7 @@ public class PlayoutModeSchedulerMultiple : PlayoutModeSchedulerBase<ProgramSche
scheduleItem, scheduleItem,
playoutItem, playoutItem,
itemChapters, itemChapters,
log: true, true,
cancellationToken)); cancellationToken));
nextState = nextState with nextState = nextState with

2
ErsatzTV.Core/Scheduling/PlayoutModeSchedulerOne.cs

@ -59,7 +59,7 @@ public class PlayoutModeSchedulerOne : PlayoutModeSchedulerBase<ProgramScheduleI
scheduleItem, scheduleItem,
playoutItem, playoutItem,
itemChapters, itemChapters,
log: true, true,
cancellationToken); cancellationToken);
PlayoutBuilderState nextState = playoutBuilderState with PlayoutBuilderState nextState = playoutBuilderState with

8
ErsatzTV.Core/Scheduling/RandomizedMediaCollectionEnumerator.cs

@ -6,8 +6,8 @@ namespace ErsatzTV.Core.Scheduling;
public class RandomizedMediaCollectionEnumerator : IMediaCollectionEnumerator public class RandomizedMediaCollectionEnumerator : IMediaCollectionEnumerator
{ {
private readonly IList<MediaItem> _mediaItems;
private readonly Lazy<Option<TimeSpan>> _lazyMinimumDuration; private readonly Lazy<Option<TimeSpan>> _lazyMinimumDuration;
private readonly IList<MediaItem> _mediaItems;
private readonly Random _random; private readonly Random _random;
private int _index; private int _index;
@ -27,11 +27,9 @@ public class RandomizedMediaCollectionEnumerator : IMediaCollectionEnumerator
} }
} }
public void ResetState(CollectionEnumeratorState state) public void ResetState(CollectionEnumeratorState state) =>
{
// seed never changes here, no need to reset // seed never changes here, no need to reset
State.Index = state.Index; State.Index = state.Index;
}
public CollectionEnumeratorState State { get; } public CollectionEnumeratorState State { get; }
@ -44,6 +42,6 @@ public class RandomizedMediaCollectionEnumerator : IMediaCollectionEnumerator
} }
public Option<TimeSpan> MinimumDuration => _lazyMinimumDuration.Value; public Option<TimeSpan> MinimumDuration => _lazyMinimumDuration.Value;
public int Count => _mediaItems.Count; public int Count => _mediaItems.Count;
} }

8
ErsatzTV.Core/Scheduling/SeasonEpisodeMediaCollectionEnumerator.cs

@ -6,8 +6,8 @@ namespace ErsatzTV.Core.Scheduling;
public sealed class SeasonEpisodeMediaCollectionEnumerator : IMediaCollectionEnumerator public sealed class SeasonEpisodeMediaCollectionEnumerator : IMediaCollectionEnumerator
{ {
private readonly IList<MediaItem> _sortedMediaItems;
private readonly Lazy<Option<TimeSpan>> _lazyMinimumDuration; private readonly Lazy<Option<TimeSpan>> _lazyMinimumDuration;
private readonly IList<MediaItem> _sortedMediaItems;
public SeasonEpisodeMediaCollectionEnumerator( public SeasonEpisodeMediaCollectionEnumerator(
IEnumerable<MediaItem> mediaItems, IEnumerable<MediaItem> mediaItems,
@ -31,11 +31,9 @@ public sealed class SeasonEpisodeMediaCollectionEnumerator : IMediaCollectionEnu
} }
} }
public void ResetState(CollectionEnumeratorState state) public void ResetState(CollectionEnumeratorState state) =>
{
// seed doesn't matter here // seed doesn't matter here
State.Index = state.Index; State.Index = state.Index;
}
public CollectionEnumeratorState State { get; } public CollectionEnumeratorState State { get; }
@ -44,6 +42,6 @@ public sealed class SeasonEpisodeMediaCollectionEnumerator : IMediaCollectionEnu
public void MoveNext() => State.Index = (State.Index + 1) % _sortedMediaItems.Count; public void MoveNext() => State.Index = (State.Index + 1) % _sortedMediaItems.Count;
public Option<TimeSpan> MinimumDuration => _lazyMinimumDuration.Value; public Option<TimeSpan> MinimumDuration => _lazyMinimumDuration.Value;
public int Count => _sortedMediaItems.Count; public int Count => _sortedMediaItems.Count;
} }

10
ErsatzTV.Core/Scheduling/ShuffleInOrderCollectionEnumerator.cs

@ -8,11 +8,11 @@ public class ShuffleInOrderCollectionEnumerator : IMediaCollectionEnumerator
{ {
private readonly CancellationToken _cancellationToken; private readonly CancellationToken _cancellationToken;
private readonly IList<CollectionWithItems> _collections; private readonly IList<CollectionWithItems> _collections;
private readonly Lazy<Option<TimeSpan>> _lazyMinimumDuration;
private readonly int _mediaItemCount; private readonly int _mediaItemCount;
private readonly bool _randomStartPoint; private readonly bool _randomStartPoint;
private Random _random; private Random _random;
private IList<MediaItem> _shuffled; private IList<MediaItem> _shuffled;
private readonly Lazy<Option<TimeSpan>> _lazyMinimumDuration;
public ShuffleInOrderCollectionEnumerator( public ShuffleInOrderCollectionEnumerator(
IList<CollectionWithItems> collections, IList<CollectionWithItems> collections,
@ -82,6 +82,10 @@ public class ShuffleInOrderCollectionEnumerator : IMediaCollectionEnumerator
State.Index %= _shuffled.Count; State.Index %= _shuffled.Count;
} }
public Option<TimeSpan> MinimumDuration => _lazyMinimumDuration.Value;
public int Count => _shuffled.Count;
private IList<MediaItem> Shuffle(IList<CollectionWithItems> collections, Random random) private IList<MediaItem> Shuffle(IList<CollectionWithItems> collections, Random random)
{ {
// based on https://keyj.emphy.de/balanced-shuffle/ // based on https://keyj.emphy.de/balanced-shuffle/
@ -217,8 +221,4 @@ public class ShuffleInOrderCollectionEnumerator : IMediaCollectionEnumerator
public int Index { get; set; } public int Index { get; set; }
public IList<Option<MediaItem>> Items { get; set; } public IList<Option<MediaItem>> Items { get; set; }
} }
public Option<TimeSpan> MinimumDuration => _lazyMinimumDuration.Value;
public int Count => _shuffled.Count;
} }

10
ErsatzTV.Core/Scheduling/ShuffledMediaCollectionEnumerator.cs

@ -7,9 +7,9 @@ namespace ErsatzTV.Core.Scheduling;
public class ShuffledMediaCollectionEnumerator : IMediaCollectionEnumerator public class ShuffledMediaCollectionEnumerator : IMediaCollectionEnumerator
{ {
private readonly CancellationToken _cancellationToken; private readonly CancellationToken _cancellationToken;
private readonly Lazy<Option<TimeSpan>> _lazyMinimumDuration;
private readonly int _mediaItemCount; private readonly int _mediaItemCount;
private readonly IList<GroupedMediaItem> _mediaItems; private readonly IList<GroupedMediaItem> _mediaItems;
private readonly Lazy<Option<TimeSpan>> _lazyMinimumDuration;
private CloneableRandom _random; private CloneableRandom _random;
private IList<MediaItem> _shuffled; private IList<MediaItem> _shuffled;
@ -79,6 +79,10 @@ public class ShuffledMediaCollectionEnumerator : IMediaCollectionEnumerator
State.Index %= _mediaItemCount; State.Index %= _mediaItemCount;
} }
public Option<TimeSpan> MinimumDuration => _lazyMinimumDuration.Value;
public int Count => _shuffled.Count;
private IList<MediaItem> Shuffle(IEnumerable<GroupedMediaItem> list, CloneableRandom random) private IList<MediaItem> Shuffle(IEnumerable<GroupedMediaItem> list, CloneableRandom random)
{ {
GroupedMediaItem[] copy = list.ToArray(); GroupedMediaItem[] copy = list.ToArray();
@ -93,8 +97,4 @@ public class ShuffledMediaCollectionEnumerator : IMediaCollectionEnumerator
return GroupedMediaItem.FlattenGroups(copy, _mediaItemCount); return GroupedMediaItem.FlattenGroups(copy, _mediaItemCount);
} }
public Option<TimeSpan> MinimumDuration => _lazyMinimumDuration.Value;
public int Count => _shuffled.Count;
} }

10
ErsatzTV.Core/SystemStartup.cs

@ -5,15 +5,15 @@ public class SystemStartup
private readonly SemaphoreSlim _databaseStartup = new(0, 100); private readonly SemaphoreSlim _databaseStartup = new(0, 100);
private readonly SemaphoreSlim _searchIndexStartup = new(0, 100); private readonly SemaphoreSlim _searchIndexStartup = new(0, 100);
public event EventHandler OnDatabaseReady;
public event EventHandler OnSearchIndexReady;
public bool IsDatabaseReady { get; private set; } public bool IsDatabaseReady { get; private set; }
public bool IsSearchIndexReady { get; private set; } public bool IsSearchIndexReady { get; private set; }
public event EventHandler OnDatabaseReady;
public event EventHandler OnSearchIndexReady;
public async Task WaitForDatabase(CancellationToken cancellationToken) => public async Task WaitForDatabase(CancellationToken cancellationToken) =>
await _databaseStartup.WaitAsync(cancellationToken); await _databaseStartup.WaitAsync(cancellationToken);
public async Task WaitForSearchIndex(CancellationToken cancellationToken) => public async Task WaitForSearchIndex(CancellationToken cancellationToken) =>
await _searchIndexStartup.WaitAsync(cancellationToken); await _searchIndexStartup.WaitAsync(cancellationToken);
@ -23,7 +23,7 @@ public class SystemStartup
IsDatabaseReady = true; IsDatabaseReady = true;
OnDatabaseReady?.Invoke(this, EventArgs.Empty); OnDatabaseReady?.Invoke(this, EventArgs.Empty);
} }
public void SearchIndexIsReady() public void SearchIndexIsReady()
{ {
_searchIndexStartup.Release(100); _searchIndexStartup.Release(100);

4
ErsatzTV.FFmpeg.Tests/Capabilities/Vaapi/VaapiCapabilityParserTests.cs

@ -178,7 +178,7 @@ VAProfileNone/VAEntrypointVideoProc
VA_RT_FORMAT_YUV420_10 VA_RT_FORMAT_YUV420_10
VA_RT_FORMAT_RGB32 VA_RT_FORMAT_RGB32
VA_RT_FORMAT_YUV420_10BPP"; VA_RT_FORMAT_YUV420_10BPP";
[Test] [Test]
public void ShouldParseEntrypoints() public void ShouldParseEntrypoints()
{ {
@ -188,7 +188,7 @@ VAProfileNone/VAEntrypointVideoProc
brief.Count.Should().Be(20); brief.Count.Should().Be(20);
full.Count.Should().Be(20); full.Count.Should().Be(20);
} }
[Test] [Test]
public void Full_ShouldParseRateControlModes() public void Full_ShouldParseRateControlModes()
{ {

2
ErsatzTV.FFmpeg.Tests/ErsatzTV.FFmpeg.Tests.csproj

@ -10,7 +10,7 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="FluentAssertions" Version="6.11.0" /> <PackageReference Include="FluentAssertions" Version="6.11.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="7.0.1" /> <PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="7.0.1" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.6.3" /> <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.7.0" />
<PackageReference Include="Moq" Version="4.18.4" /> <PackageReference Include="Moq" Version="4.18.4" />
<PackageReference Include="NUnit" Version="3.13.3" /> <PackageReference Include="NUnit" Version="3.13.3" />
<PackageReference Include="NUnit3TestAdapter" Version="4.5.0" /> <PackageReference Include="NUnit3TestAdapter" Version="4.5.0" />

2
ErsatzTV.FFmpeg/Capabilities/DefaultHardwareCapabilities.cs

@ -35,7 +35,7 @@ public class DefaultHardwareCapabilities : IHardwareCapabilities
_ => FFmpegCapability.Hardware _ => FFmpegCapability.Hardware
}; };
} }
public Option<RateControlMode> GetRateControlMode(string videoFormat, Option<IPixelFormat> maybePixelFormat) => public Option<RateControlMode> GetRateControlMode(string videoFormat, Option<IPixelFormat> maybePixelFormat) =>
Option<RateControlMode>.None; Option<RateControlMode>.None;
} }

2
ErsatzTV.FFmpeg/Capabilities/NoHardwareCapabilities.cs

@ -15,7 +15,7 @@ public class NoHardwareCapabilities : IHardwareCapabilities
Option<string> videoProfile, Option<string> videoProfile,
Option<IPixelFormat> maybePixelFormat) => Option<IPixelFormat> maybePixelFormat) =>
FFmpegCapability.Software; FFmpegCapability.Software;
public Option<RateControlMode> GetRateControlMode(string videoFormat, Option<IPixelFormat> maybePixelFormat) => public Option<RateControlMode> GetRateControlMode(string videoFormat, Option<IPixelFormat> maybePixelFormat) =>
Option<RateControlMode>.None; Option<RateControlMode>.None;
} }

6
ErsatzTV.FFmpeg/Capabilities/NvidiaHardwareCapabilities.cs

@ -105,6 +105,9 @@ public class NvidiaHardwareCapabilities : IHardwareCapabilities
return isHardware ? FFmpegCapability.Hardware : FFmpegCapability.Software; return isHardware ? FFmpegCapability.Hardware : FFmpegCapability.Software;
} }
public Option<RateControlMode> GetRateControlMode(string videoFormat, Option<IPixelFormat> maybePixelFormat) =>
Option<RateControlMode>.None;
private FFmpegCapability CheckHardwareCodec(string codec, Func<string, bool> check) private FFmpegCapability CheckHardwareCodec(string codec, Func<string, bool> check)
{ {
if (check(codec)) if (check(codec))
@ -115,7 +118,4 @@ public class NvidiaHardwareCapabilities : IHardwareCapabilities
_logger.LogWarning("FFmpeg does not contain codec {Codec}; will fall back to software codec", codec); _logger.LogWarning("FFmpeg does not contain codec {Codec}; will fall back to software codec", codec);
return FFmpegCapability.Software; return FFmpegCapability.Software;
} }
public Option<RateControlMode> GetRateControlMode(string videoFormat, Option<IPixelFormat> maybePixelFormat) =>
Option<RateControlMode>.None;
} }

2
ErsatzTV.FFmpeg/Capabilities/Vaapi/VaapiCapabilityParser.cs

@ -23,7 +23,7 @@ public static class VaapiCapabilityParser
return profileEntrypoints; return profileEntrypoints;
} }
public static List<VaapiProfileEntrypoint> ParseFull(string output) public static List<VaapiProfileEntrypoint> ParseFull(string output)
{ {
var profileEntrypoints = new List<VaapiProfileEntrypoint>(); var profileEntrypoints = new List<VaapiProfileEntrypoint>();

5
ErsatzTV.FFmpeg/Capabilities/Vaapi/VaapiProfileEntrypoint.cs

@ -6,8 +6,5 @@ public record VaapiProfileEntrypoint(string VaapiProfile, string VaapiEntrypoint
public IReadOnlyCollection<RateControlMode> RateControlModes => _rateControlModes; public IReadOnlyCollection<RateControlMode> RateControlModes => _rateControlModes;
public bool AddRateControlMode(RateControlMode mode) public bool AddRateControlMode(RateControlMode mode) => _rateControlModes.Add(mode);
{
return _rateControlModes.Add(mode);
}
} }

220
ErsatzTV.FFmpeg/Capabilities/VaapiHardwareCapabilities.cs

@ -31,116 +31,132 @@ public class VaapiHardwareCapabilities : IHardwareCapabilities
(VideoFormat.H264, "baseline" or "66") => false, (VideoFormat.H264, "baseline" or "66") => false,
(VideoFormat.H264, "main" or "77") => (VideoFormat.H264, "main" or "77") =>
_profileEntrypoints.Any(e => e is _profileEntrypoints.Any(
{ e => e is
VaapiProfile: VaapiProfile.H264Main, {
VaapiEntrypoint: VaapiEntrypoint.Decode VaapiProfile: VaapiProfile.H264Main,
}), VaapiEntrypoint: VaapiEntrypoint.Decode
}),
(VideoFormat.H264, "high" or "100") => (VideoFormat.H264, "high" or "100") =>
_profileEntrypoints.Any(e => e is _profileEntrypoints.Any(
{ e => e is
VaapiProfile: VaapiProfile.H264High, {
VaapiEntrypoint: VaapiEntrypoint.Decode VaapiProfile: VaapiProfile.H264High,
}), VaapiEntrypoint: VaapiEntrypoint.Decode
}),
(VideoFormat.H264, "high 10" or "110") => (VideoFormat.H264, "high 10" or "110") =>
_profileEntrypoints.Any(e => e is _profileEntrypoints.Any(
{ e => e is
VaapiProfile: VaapiProfile.H264High, {
VaapiEntrypoint: VaapiEntrypoint.Decode VaapiProfile: VaapiProfile.H264High,
}), VaapiEntrypoint: VaapiEntrypoint.Decode
}),
(VideoFormat.H264, "baseline constrained" or "578") => (VideoFormat.H264, "baseline constrained" or "578") =>
_profileEntrypoints.Any(e => e is _profileEntrypoints.Any(
{ e => e is
VaapiProfile: VaapiProfile.H264ConstrainedBaseline, {
VaapiEntrypoint: VaapiEntrypoint.Decode VaapiProfile: VaapiProfile.H264ConstrainedBaseline,
}), VaapiEntrypoint: VaapiEntrypoint.Decode
}),
(VideoFormat.Mpeg2Video, "main" or "4") => (VideoFormat.Mpeg2Video, "main" or "4") =>
_profileEntrypoints.Any(e => e is _profileEntrypoints.Any(
{ e => e is
VaapiProfile: VaapiProfile.Mpeg2Main, {
VaapiEntrypoint: VaapiEntrypoint.Decode VaapiProfile: VaapiProfile.Mpeg2Main,
}), VaapiEntrypoint: VaapiEntrypoint.Decode
}),
(VideoFormat.Mpeg2Video, "simple" or "5") => (VideoFormat.Mpeg2Video, "simple" or "5") =>
_profileEntrypoints.Any(e => e is _profileEntrypoints.Any(
{ e => e is
VaapiProfile: VaapiProfile.Mpeg2Simple, {
VaapiEntrypoint: VaapiEntrypoint.Decode VaapiProfile: VaapiProfile.Mpeg2Simple,
}), VaapiEntrypoint: VaapiEntrypoint.Decode
}),
(VideoFormat.Vc1, "simple" or "0") => (VideoFormat.Vc1, "simple" or "0") =>
_profileEntrypoints.Any(e => e is _profileEntrypoints.Any(
{ e => e is
VaapiProfile: VaapiProfile.Vc1Simple, {
VaapiEntrypoint: VaapiEntrypoint.Decode VaapiProfile: VaapiProfile.Vc1Simple,
}), VaapiEntrypoint: VaapiEntrypoint.Decode
}),
(VideoFormat.Vc1, "main" or "1") => (VideoFormat.Vc1, "main" or "1") =>
_profileEntrypoints.Any(e => e is _profileEntrypoints.Any(
{ e => e is
VaapiProfile: VaapiProfile.Vc1Main, {
VaapiEntrypoint: VaapiEntrypoint.Decode VaapiProfile: VaapiProfile.Vc1Main,
}), VaapiEntrypoint: VaapiEntrypoint.Decode
}),
(VideoFormat.Vc1, "advanced" or "3") => (VideoFormat.Vc1, "advanced" or "3") =>
_profileEntrypoints.Any(e => e is _profileEntrypoints.Any(
{ e => e is
VaapiProfile: VaapiProfile.Vc1Advanced, {
VaapiEntrypoint: VaapiEntrypoint.Decode VaapiProfile: VaapiProfile.Vc1Advanced,
}), VaapiEntrypoint: VaapiEntrypoint.Decode
}),
(VideoFormat.Hevc, "main" or "1") => (VideoFormat.Hevc, "main" or "1") =>
_profileEntrypoints.Any(e => e is _profileEntrypoints.Any(
{ e => e is
VaapiProfile: VaapiProfile.HevcMain, {
VaapiEntrypoint: VaapiEntrypoint.Decode VaapiProfile: VaapiProfile.HevcMain,
}), VaapiEntrypoint: VaapiEntrypoint.Decode
}),
(VideoFormat.Hevc, "main 10" or "2") => (VideoFormat.Hevc, "main 10" or "2") =>
_profileEntrypoints.Any(e => e is _profileEntrypoints.Any(
{ e => e is
VaapiProfile: VaapiProfile.HevcMain10, {
VaapiEntrypoint: VaapiEntrypoint.Decode VaapiProfile: VaapiProfile.HevcMain10,
}), VaapiEntrypoint: VaapiEntrypoint.Decode
}),
(VideoFormat.Vp9, "profile 0" or "0") => (VideoFormat.Vp9, "profile 0" or "0") =>
_profileEntrypoints.Any(e => e is _profileEntrypoints.Any(
{ e => e is
VaapiProfile: VaapiProfile.Vp9Profile0, {
VaapiEntrypoint: VaapiEntrypoint.Decode VaapiProfile: VaapiProfile.Vp9Profile0,
}), VaapiEntrypoint: VaapiEntrypoint.Decode
}),
(VideoFormat.Vp9, "profile 1" or "1") => (VideoFormat.Vp9, "profile 1" or "1") =>
_profileEntrypoints.Any(e => e is _profileEntrypoints.Any(
{ e => e is
VaapiProfile: VaapiProfile.Vp9Profile1, {
VaapiEntrypoint: VaapiEntrypoint.Decode VaapiProfile: VaapiProfile.Vp9Profile1,
}), VaapiEntrypoint: VaapiEntrypoint.Decode
}),
(VideoFormat.Vp9, "profile 2" or "2") => (VideoFormat.Vp9, "profile 2" or "2") =>
_profileEntrypoints.Any(e => e is _profileEntrypoints.Any(
{ e => e is
VaapiProfile: VaapiProfile.Vp9Profile2, {
VaapiEntrypoint: VaapiEntrypoint.Decode VaapiProfile: VaapiProfile.Vp9Profile2,
}), VaapiEntrypoint: VaapiEntrypoint.Decode
}),
(VideoFormat.Vp9, "profile 3" or "3") => (VideoFormat.Vp9, "profile 3" or "3") =>
_profileEntrypoints.Any(e => e is _profileEntrypoints.Any(
{ e => e is
VaapiProfile: VaapiProfile.Vp9Profile3, {
VaapiEntrypoint: VaapiEntrypoint.Decode VaapiProfile: VaapiProfile.Vp9Profile3,
}), VaapiEntrypoint: VaapiEntrypoint.Decode
}),
(VideoFormat.Av1, "main" or "0") => (VideoFormat.Av1, "main" or "0") =>
_profileEntrypoints.Any(e => e is _profileEntrypoints.Any(
{ e => e is
VaapiProfile: VaapiProfile.Av1Profile0, {
VaapiEntrypoint: VaapiEntrypoint.Decode VaapiProfile: VaapiProfile.Av1Profile0,
}), VaapiEntrypoint: VaapiEntrypoint.Decode
}),
// fall back to software decoder // fall back to software decoder
_ => false _ => false
@ -170,32 +186,36 @@ public class VaapiHardwareCapabilities : IHardwareCapabilities
VideoFormat.H264 when bitDepth == 10 => false, VideoFormat.H264 when bitDepth == 10 => false,
VideoFormat.H264 => VideoFormat.H264 =>
_profileEntrypoints.Any(e => e is _profileEntrypoints.Any(
{ e => e is
VaapiProfile: VaapiProfile.H264Main, {
VaapiEntrypoint: VaapiEntrypoint.Encode or VaapiEntrypoint.EncodeLowPower VaapiProfile: VaapiProfile.H264Main,
}), VaapiEntrypoint: VaapiEntrypoint.Encode or VaapiEntrypoint.EncodeLowPower
}),
VideoFormat.Hevc when bitDepth == 10 => VideoFormat.Hevc when bitDepth == 10 =>
_profileEntrypoints.Any(e => e is _profileEntrypoints.Any(
{ e => e is
VaapiProfile: VaapiProfile.HevcMain10, {
VaapiEntrypoint: VaapiEntrypoint.Encode or VaapiEntrypoint.EncodeLowPower VaapiProfile: VaapiProfile.HevcMain10,
}), VaapiEntrypoint: VaapiEntrypoint.Encode or VaapiEntrypoint.EncodeLowPower
}),
VideoFormat.Hevc => VideoFormat.Hevc =>
_profileEntrypoints.Any(e => e is _profileEntrypoints.Any(
{ e => e is
VaapiProfile: VaapiProfile.HevcMain, {
VaapiEntrypoint: VaapiEntrypoint.Encode or VaapiEntrypoint.EncodeLowPower VaapiProfile: VaapiProfile.HevcMain,
}), VaapiEntrypoint: VaapiEntrypoint.Encode or VaapiEntrypoint.EncodeLowPower
}),
VideoFormat.Mpeg2Video => VideoFormat.Mpeg2Video =>
_profileEntrypoints.Any(e => e is _profileEntrypoints.Any(
{ e => e is
VaapiProfile: VaapiProfile.Mpeg2Main, {
VaapiEntrypoint: VaapiEntrypoint.Encode or VaapiEntrypoint.EncodeLowPower VaapiProfile: VaapiProfile.Mpeg2Main,
}), VaapiEntrypoint: VaapiEntrypoint.Encode or VaapiEntrypoint.EncodeLowPower
}),
_ => false _ => false
}; };

8
ErsatzTV.FFmpeg/Encoder/Vaapi/EncoderH264Vaapi.cs

@ -7,11 +7,11 @@ public class EncoderH264Vaapi : EncoderBase
private readonly RateControlMode _rateControlMode; private readonly RateControlMode _rateControlMode;
public EncoderH264Vaapi(RateControlMode rateControlMode) => _rateControlMode = rateControlMode; public EncoderH264Vaapi(RateControlMode rateControlMode) => _rateControlMode = rateControlMode;
public override string Name => "h264_vaapi"; public override string Name => "h264_vaapi";
public override StreamKind Kind => StreamKind.Video; public override StreamKind Kind => StreamKind.Video;
public override IList<string> OutputOptions public override IList<string> OutputOptions
{ {
get get
@ -23,7 +23,7 @@ public class EncoderH264Vaapi : EncoderBase
result.Add("-rc_mode"); result.Add("-rc_mode");
result.Add("1"); result.Add("1");
} }
return result; return result;
} }
} }

4
ErsatzTV.FFmpeg/Encoder/Vaapi/EncoderHevcVaapi.cs

@ -9,7 +9,7 @@ public class EncoderHevcVaapi : EncoderBase
public EncoderHevcVaapi(RateControlMode rateControlMode) => _rateControlMode = rateControlMode; public EncoderHevcVaapi(RateControlMode rateControlMode) => _rateControlMode = rateControlMode;
public override string Name => "hevc_vaapi"; public override string Name => "hevc_vaapi";
public override StreamKind Kind => StreamKind.Video; public override StreamKind Kind => StreamKind.Video;
public override IList<string> OutputOptions public override IList<string> OutputOptions
@ -23,7 +23,7 @@ public class EncoderHevcVaapi : EncoderBase
result.Add("-rc_mode"); result.Add("-rc_mode");
result.Add("1"); result.Add("1");
} }
return result; return result;
} }
} }

6
ErsatzTV.FFmpeg/Encoder/Vaapi/EncoderMpeg2Vaapi.cs

@ -9,9 +9,9 @@ public class EncoderMpeg2Vaapi : EncoderBase
public EncoderMpeg2Vaapi(RateControlMode rateControlMode) => _rateControlMode = rateControlMode; public EncoderMpeg2Vaapi(RateControlMode rateControlMode) => _rateControlMode = rateControlMode;
public override string Name => "mpeg2_vaapi"; public override string Name => "mpeg2_vaapi";
public override StreamKind Kind => StreamKind.Video; public override StreamKind Kind => StreamKind.Video;
public override IList<string> OutputOptions public override IList<string> OutputOptions
{ {
get get
@ -23,7 +23,7 @@ public class EncoderMpeg2Vaapi : EncoderBase
result.Add("-rc_mode"); result.Add("-rc_mode");
result.Add("1"); result.Add("1");
} }
return result; return result;
} }
} }

5
ErsatzTV.FFmpeg/Filter/ComplexFilter.cs

@ -238,7 +238,10 @@ public class ComplexFilter : IPipelineStep
foreach (SubtitleInputFile subtitleInputFile in _maybeSubtitleInputFile.Filter( foreach (SubtitleInputFile subtitleInputFile in _maybeSubtitleInputFile.Filter(
s => s.Method == SubtitleMethod.Copy || s => s.Method == SubtitleMethod.Copy ||
s is { IsImageBased: true, Method: SubtitleMethod.Convert })) // TODO: support converting text subtitles? s is
{
IsImageBased: true, Method: SubtitleMethod.Convert
})) // TODO: support converting text subtitles?
{ {
if (subtitleInputFile.Streams.Any()) if (subtitleInputFile.Streams.Any())
{ {

3
ErsatzTV.FFmpeg/Option/Mp4OutputOptions.cs

@ -2,5 +2,6 @@
public class Mp4OutputOptions : OutputOption public class Mp4OutputOptions : OutputOption
{ {
public override IList<string> OutputOptions => new List<string> { "-movflags", "+faststart+frag_keyframe+separate_moof+omit_tfhd_offset+empty_moov+delay_moov" }; public override IList<string> OutputOptions => new List<string>
{ "-movflags", "+faststart+frag_keyframe+separate_moof+omit_tfhd_offset+empty_moov+delay_moov" };
} }

2
ErsatzTV.FFmpeg/Pipeline/PipelineBuilderBase.cs

@ -146,7 +146,7 @@ public abstract class PipelineBuilderBase : IPipelineBuilder
{ {
outputOption = new Mp4OutputOptions(); outputOption = new Mp4OutputOptions();
} }
var pipelineSteps = new List<IPipelineStep> var pipelineSteps = new List<IPipelineStep>
{ {
new NoStandardInputOption(), new NoStandardInputOption(),

2
ErsatzTV.FFmpeg/Pipeline/QsvPipelineBuilder.cs

@ -491,7 +491,7 @@ public class QsvPipelineBuilder : SoftwarePipelineBuilder
pf = availablePixelFormat; pf = availablePixelFormat;
} }
} }
// only scale if scaling or padding was used for main video stream // only scale if scaling or padding was used for main video stream
if (videoInputFile.FilterSteps.Any(s => s is ScaleFilter or ScaleFilter or PadFilter)) if (videoInputFile.FilterSteps.Any(s => s is ScaleFilter or ScaleFilter or PadFilter))
{ {

4
ErsatzTV.FFmpeg/Pipeline/VaapiPipelineBuilder.cs

@ -200,7 +200,7 @@ public class VaapiPipelineBuilder : SoftwarePipelineBuilder
RateControlMode rateControlMode = RateControlMode rateControlMode =
_hardwareCapabilities.GetRateControlMode(desiredState.VideoFormat, desiredState.PixelFormat) _hardwareCapabilities.GetRateControlMode(desiredState.VideoFormat, desiredState.PixelFormat)
.IfNone(RateControlMode.VBR); .IfNone(RateControlMode.VBR);
Option<IEncoder> maybeEncoder = Option<IEncoder> maybeEncoder =
(ffmpegState.EncoderHardwareAccelerationMode, desiredState.VideoFormat) switch (ffmpegState.EncoderHardwareAccelerationMode, desiredState.VideoFormat) switch
{ {
@ -425,7 +425,7 @@ public class VaapiPipelineBuilder : SoftwarePipelineBuilder
var downloadFilter = new HardwareDownloadFilter(currentState); var downloadFilter = new HardwareDownloadFilter(currentState);
currentState = downloadFilter.NextState(currentState); currentState = downloadFilter.NextState(currentState);
videoInputFile.FilterSteps.Add(downloadFilter); videoInputFile.FilterSteps.Add(downloadFilter);
foreach (IPixelFormat pixelFormat in desiredState.PixelFormat) foreach (IPixelFormat pixelFormat in desiredState.PixelFormat)
{ {
IPixelFormat pf = pixelFormat; IPixelFormat pf = pixelFormat;

2
ErsatzTV.Infrastructure.Tests/ErsatzTV.Infrastructure.Tests.csproj

@ -10,7 +10,7 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="FluentAssertions" Version="6.11.0" /> <PackageReference Include="FluentAssertions" Version="6.11.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.6.3" /> <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.7.0" />
<PackageReference Include="Moq" Version="4.18.4" /> <PackageReference Include="Moq" Version="4.18.4" />
<PackageReference Include="NUnit" Version="3.13.3" /> <PackageReference Include="NUnit" Version="3.13.3" />
<PackageReference Include="NUnit3TestAdapter" Version="4.5.0" /> <PackageReference Include="NUnit3TestAdapter" Version="4.5.0" />

2
ErsatzTV.Infrastructure/ErsatzTV.Infrastructure.csproj

@ -11,7 +11,7 @@
<PackageReference Include="Blurhash.ImageSharp" Version="3.0.0" /> <PackageReference Include="Blurhash.ImageSharp" Version="3.0.0" />
<PackageReference Include="CliWrap" Version="3.6.4" /> <PackageReference Include="CliWrap" Version="3.6.4" />
<PackageReference Include="Dapper" Version="2.0.143" /> <PackageReference Include="Dapper" Version="2.0.143" />
<PackageReference Include="Jint" Version="3.0.0-beta-2049" /> <PackageReference Include="Jint" Version="3.0.0-beta-2050" />
<PackageReference Include="Lucene.Net" Version="4.8.0-beta00016" /> <PackageReference Include="Lucene.Net" Version="4.8.0-beta00016" />
<PackageReference Include="Lucene.Net.Analysis.Common" 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="Lucene.Net.QueryParser" Version="4.8.0-beta00016" />

2
ErsatzTV.Infrastructure/Locking/EntityLocker.cs

@ -158,7 +158,7 @@ public class EntityLocker : IEntityLocker
} }
public bool AreEmbyCollectionsLocked() => _embyCollections; public bool AreEmbyCollectionsLocked() => _embyCollections;
public bool LockPlayout(int playoutId) public bool LockPlayout(int playoutId)
{ {
if (!_lockedPlayouts.ContainsKey(playoutId) && _lockedPlayouts.TryAdd(playoutId, 0)) if (!_lockedPlayouts.ContainsKey(playoutId) && _lockedPlayouts.TryAdd(playoutId, 0))

10
ErsatzTV.Infrastructure/Scheduling/MultiEpisodeShuffleCollectionEnumerator.cs

@ -10,13 +10,13 @@ namespace ErsatzTV.Infrastructure.Scheduling;
public class MultiEpisodeShuffleCollectionEnumerator : IMediaCollectionEnumerator public class MultiEpisodeShuffleCollectionEnumerator : IMediaCollectionEnumerator
{ {
private readonly CancellationToken _cancellationToken; private readonly CancellationToken _cancellationToken;
private readonly Lazy<Option<TimeSpan>> _lazyMinimumDuration;
private readonly ILogger _logger; private readonly ILogger _logger;
private readonly int _mediaItemCount; private readonly int _mediaItemCount;
private readonly Dictionary<int, List<MediaItem>> _mediaItemGroups; private readonly Dictionary<int, List<MediaItem>> _mediaItemGroups;
private readonly List<MediaItem> _ungrouped; private readonly List<MediaItem> _ungrouped;
private CloneableRandom _random; private CloneableRandom _random;
private IList<MediaItem> _shuffled; private IList<MediaItem> _shuffled;
private readonly Lazy<Option<TimeSpan>> _lazyMinimumDuration;
public MultiEpisodeShuffleCollectionEnumerator( public MultiEpisodeShuffleCollectionEnumerator(
ICollection<MediaItem> mediaItems, ICollection<MediaItem> mediaItems,
@ -126,6 +126,10 @@ public class MultiEpisodeShuffleCollectionEnumerator : IMediaCollectionEnumerato
State.Index %= _mediaItemCount; State.Index %= _mediaItemCount;
} }
public Option<TimeSpan> MinimumDuration => _lazyMinimumDuration.Value;
public int Count => _shuffled.Count;
private IList<MediaItem> Shuffle(CloneableRandom random) private IList<MediaItem> Shuffle(CloneableRandom random)
{ {
int maxGroupNumber = _mediaItemGroups.Max(a => a.Key); int maxGroupNumber = _mediaItemGroups.Max(a => a.Key);
@ -188,8 +192,4 @@ public class MultiEpisodeShuffleCollectionEnumerator : IMediaCollectionEnumerato
return copy; return copy;
} }
public Option<TimeSpan> MinimumDuration => _lazyMinimumDuration.Value;
public int Count => _shuffled.Count;
} }

6
ErsatzTV.Scanner.Tests/Core/FFmpeg/TranscodingTests.cs

@ -152,7 +152,7 @@ public class TranscodingTests
new("mpeg2video", "yuv420p"), new("mpeg2video", "yuv420p"),
// // // //
new("libx265", "yuv420p"), new("libx265", "yuv420p"),
new("libx265", "yuv420p10le"), new("libx265", "yuv420p10le")
// //
// new("mpeg4", "yuv420p"), // new("mpeg4", "yuv420p"),
// //
@ -191,7 +191,7 @@ public class TranscodingTests
{ {
HardwareAccelerationKind.None, HardwareAccelerationKind.None,
// HardwareAccelerationKind.Nvenc, // HardwareAccelerationKind.Nvenc,
HardwareAccelerationKind.Vaapi, HardwareAccelerationKind.Vaapi
// HardwareAccelerationKind.Qsv // HardwareAccelerationKind.Qsv
// HardwareAccelerationKind.VideoToolbox, // HardwareAccelerationKind.VideoToolbox,
// HardwareAccelerationKind.Amf // HardwareAccelerationKind.Amf
@ -744,7 +744,7 @@ public class TranscodingTests
.WithArguments(args) .WithArguments(args)
.WithValidation(CommandResultValidation.None) .WithValidation(CommandResultValidation.None)
.ExecuteBufferedAsync(); .ExecuteBufferedAsync();
string output = string.IsNullOrWhiteSpace(p1.StandardOutput) string output = string.IsNullOrWhiteSpace(p1.StandardOutput)
? p1.StandardError ? p1.StandardError
: p1.StandardOutput; : p1.StandardOutput;

2
ErsatzTV.Scanner.Tests/ErsatzTV.Scanner.Tests.csproj

@ -11,7 +11,7 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="FluentAssertions" Version="6.11.0" /> <PackageReference Include="FluentAssertions" Version="6.11.0" />
<PackageReference Include="LanguageExt.Core" Version="4.4.3" /> <PackageReference Include="LanguageExt.Core" Version="4.4.3" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.6.3" /> <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.7.0" />
<PackageReference Include="Moq" Version="4.18.4" /> <PackageReference Include="Moq" Version="4.18.4" />
<PackageReference Include="NUnit" Version="3.13.3" /> <PackageReference Include="NUnit" Version="3.13.3" />
<PackageReference Include="NUnit3TestAdapter" Version="4.5.0" /> <PackageReference Include="NUnit3TestAdapter" Version="4.5.0" />

2
ErsatzTV/Controllers/InternalController.cs

@ -78,7 +78,7 @@ public class InternalController : ControllerBase
{ {
contentType = "video/mp4"; contentType = "video/mp4";
} }
process.Start(); process.Start();
return new FileStreamResult(process.StandardOutput.BaseStream, contentType); return new FileStreamResult(process.StandardOutput.BaseStream, contentType);
}, },

6
ErsatzTV/ErsatzTV.csproj

@ -57,9 +57,9 @@
<PackageReference Include="Bugsnag.AspNet.Core" Version="3.1.0" /> <PackageReference Include="Bugsnag.AspNet.Core" Version="3.1.0" />
<PackageReference Include="FluentValidation" Version="11.6.0" /> <PackageReference Include="FluentValidation" Version="11.6.0" />
<PackageReference Include="FluentValidation.AspNetCore" Version="11.3.0" /> <PackageReference Include="FluentValidation.AspNetCore" Version="11.3.0" />
<PackageReference Include="HtmlSanitizer" Version="8.0.645" /> <PackageReference Include="HtmlSanitizer" Version="8.0.692" />
<PackageReference Include="LanguageExt.Core" Version="4.4.3" /> <PackageReference Include="LanguageExt.Core" Version="4.4.3" />
<PackageReference Include="Markdig" Version="0.31.0" /> <PackageReference Include="Markdig" Version="0.32.0" />
<PackageReference Include="MediatR.Courier.DependencyInjection" Version="5.0.0" /> <PackageReference Include="MediatR.Courier.DependencyInjection" Version="5.0.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="7.0.9" /> <PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="7.0.9" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="7.0.9" /> <PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="7.0.9" />
@ -73,7 +73,7 @@
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference> </PackageReference>
<PackageReference Include="MudBlazor" Version="6.7.0" /> <PackageReference Include="MudBlazor" Version="6.8.0" />
<PackageReference Include="NaturalSort.Extension" Version="4.0.0" /> <PackageReference Include="NaturalSort.Extension" Version="4.0.0" />
<PackageReference Include="Refit.HttpClientFactory" Version="7.0.0" /> <PackageReference Include="Refit.HttpClientFactory" Version="7.0.0" />
<PackageReference Include="Serilog" Version="3.0.1" /> <PackageReference Include="Serilog" Version="3.0.1" />

10
ErsatzTV/Pages/Index.razor

@ -105,7 +105,8 @@
} }
</MudTd> </MudTd>
</RowTemplate> </RowTemplate>
</MudTable> } </MudTable>
}
</MudContainer> </MudContainer>
@code { @code {
@ -128,11 +129,8 @@
_cts.Cancel(); _cts.Cancel();
_cts.Dispose(); _cts.Dispose();
} }
private async void OnStartupProgress(object sender, EventArgs e) private async void OnStartupProgress(object sender, EventArgs e) => await InvokeAsync(StateHasChanged);
{
await InvokeAsync(StateHasChanged);
}
protected override async Task OnParametersSetAsync() protected override async Task OnParametersSetAsync()
{ {

2
ErsatzTV/Pages/Playouts.razor

@ -149,7 +149,7 @@
_cts.Cancel(); _cts.Cancel();
_cts.Dispose(); _cts.Dispose();
} }
protected override void OnInitialized() => EntityLocker.OnPlayoutChanged += ReloadDetailsIfNeeded; protected override void OnInitialized() => EntityLocker.OnPlayoutChanged += ReloadDetailsIfNeeded;
protected override async Task OnParametersSetAsync() protected override async Task OnParametersSetAsync()

486
ErsatzTV/Pages/ScheduleItemsEditor.razor

@ -89,249 +89,249 @@
@if (_selectedItem is not null) @if (_selectedItem is not null)
{ {
<EditForm Model="_selectedItem"> <EditForm Model="_selectedItem">
<FluentValidationValidator/> <FluentValidationValidator/>
<div style="display: flex; flex-direction: row;" class="mt-6"> <div style="display: flex; flex-direction: row;" class="mt-6">
<div style="flex-grow: 1; max-width: 400px;" class="mr-6"> <div style="flex-grow: 1; max-width: 400px;" class="mr-6">
<MudCard> <MudCard>
<MudCardContent> <MudCardContent>
<MudSelect Label="Start Type" @bind-Value="_selectedItem.StartType" For="@(() => _selectedItem.StartType)"> <MudSelect Label="Start Type" @bind-Value="_selectedItem.StartType" For="@(() => _selectedItem.StartType)">
<MudSelectItem Value="StartType.Dynamic">Dynamic</MudSelectItem> <MudSelectItem Value="StartType.Dynamic">Dynamic</MudSelectItem>
@if (!_schedule.ShuffleScheduleItems) @if (!_schedule.ShuffleScheduleItems)
{ {
<MudSelectItem Value="StartType.Fixed">Fixed</MudSelectItem> <MudSelectItem Value="StartType.Fixed">Fixed</MudSelectItem>
} }
</MudSelect> </MudSelect>
<MudTimePicker Class="mt-3" Label="Start Time" @bind-Time="@_selectedItem.StartTime" For="@(() => _selectedItem.StartTime)" Disabled="@(_selectedItem.StartType == StartType.Dynamic)"/> <MudTimePicker Class="mt-3" Label="Start Time" @bind-Time="@_selectedItem.StartTime" For="@(() => _selectedItem.StartTime)" Disabled="@(_selectedItem.StartType == StartType.Dynamic)"/>
<MudSelect Class="mt-3" Label="Collection Type" @bind-Value="_selectedItem.CollectionType" For="@(() => _selectedItem.CollectionType)"> <MudSelect Class="mt-3" Label="Collection Type" @bind-Value="_selectedItem.CollectionType" For="@(() => _selectedItem.CollectionType)">
<MudSelectItem Value="ProgramScheduleItemCollectionType.Collection">Collection</MudSelectItem> <MudSelectItem Value="ProgramScheduleItemCollectionType.Collection">Collection</MudSelectItem>
<MudSelectItem Value="ProgramScheduleItemCollectionType.TelevisionShow">Television Show</MudSelectItem> <MudSelectItem Value="ProgramScheduleItemCollectionType.TelevisionShow">Television Show</MudSelectItem>
<MudSelectItem Value="ProgramScheduleItemCollectionType.TelevisionSeason">Television Season</MudSelectItem> <MudSelectItem Value="ProgramScheduleItemCollectionType.TelevisionSeason">Television Season</MudSelectItem>
<MudSelectItem Value="ProgramScheduleItemCollectionType.Artist">Artist</MudSelectItem> <MudSelectItem Value="ProgramScheduleItemCollectionType.Artist">Artist</MudSelectItem>
<MudSelectItem Value="ProgramScheduleItemCollectionType.MultiCollection">Multi Collection</MudSelectItem> <MudSelectItem Value="ProgramScheduleItemCollectionType.MultiCollection">Multi Collection</MudSelectItem>
<MudSelectItem Value="ProgramScheduleItemCollectionType.SmartCollection">Smart Collection</MudSelectItem> <MudSelectItem Value="ProgramScheduleItemCollectionType.SmartCollection">Smart Collection</MudSelectItem>
</MudSelect> </MudSelect>
@if (_selectedItem.CollectionType == ProgramScheduleItemCollectionType.Collection) @if (_selectedItem.CollectionType == ProgramScheduleItemCollectionType.Collection)
{ {
<MudAutocomplete Class="mt-3" T="MediaCollectionViewModel" Label="Collection" <MudAutocomplete Class="mt-3" T="MediaCollectionViewModel" Label="Collection"
@bind-Value="_selectedItem.Collection" SearchFunc="@SearchCollections" @bind-Value="_selectedItem.Collection" SearchFunc="@SearchCollections"
ToStringFunc="@(c => c?.Name)" Placeholder="Type to search..."> ToStringFunc="@(c => c?.Name)" Placeholder="Type to search...">
<MoreItemsTemplate> <MoreItemsTemplate>
<MudText Align="Align.Center" Class="px-4 py-1"> <MudText Align="Align.Center" Class="px-4 py-1">
Only the first 10 items are shown Only the first 10 items are shown
</MudText> </MudText>
</MoreItemsTemplate> </MoreItemsTemplate>
</MudAutocomplete> </MudAutocomplete>
} }
@if (_selectedItem.CollectionType == ProgramScheduleItemCollectionType.MultiCollection) @if (_selectedItem.CollectionType == ProgramScheduleItemCollectionType.MultiCollection)
{ {
<MudAutocomplete Class="mt-3" T="MultiCollectionViewModel" Label="Multi Collection" <MudAutocomplete Class="mt-3" T="MultiCollectionViewModel" Label="Multi Collection"
@bind-Value="_selectedItem.MultiCollection" SearchFunc="@SearchMultiCollections" @bind-Value="_selectedItem.MultiCollection" SearchFunc="@SearchMultiCollections"
ToStringFunc="@(c => c?.Name)" Placeholder="Type to search..."> ToStringFunc="@(c => c?.Name)" Placeholder="Type to search...">
<MoreItemsTemplate> <MoreItemsTemplate>
<MudText Align="Align.Center" Class="px-4 py-1"> <MudText Align="Align.Center" Class="px-4 py-1">
Only the first 10 items are shown Only the first 10 items are shown
</MudText> </MudText>
</MoreItemsTemplate> </MoreItemsTemplate>
</MudAutocomplete> </MudAutocomplete>
} }
@if (_selectedItem.CollectionType == ProgramScheduleItemCollectionType.SmartCollection) @if (_selectedItem.CollectionType == ProgramScheduleItemCollectionType.SmartCollection)
{ {
<MudAutocomplete Class="mt-3" T="SmartCollectionViewModel" Label="Smart Collection" <MudAutocomplete Class="mt-3" T="SmartCollectionViewModel" Label="Smart Collection"
@bind-Value="_selectedItem.SmartCollection" SearchFunc="@SearchSmartCollections" @bind-Value="_selectedItem.SmartCollection" SearchFunc="@SearchSmartCollections"
ToStringFunc="@(c => c?.Name)" Placeholder="Type to search..."> ToStringFunc="@(c => c?.Name)" Placeholder="Type to search...">
<MoreItemsTemplate> <MoreItemsTemplate>
<MudText Align="Align.Center" Class="px-4 py-1"> <MudText Align="Align.Center" Class="px-4 py-1">
Only the first 10 items are shown Only the first 10 items are shown
</MudText> </MudText>
</MoreItemsTemplate> </MoreItemsTemplate>
</MudAutocomplete> </MudAutocomplete>
} }
@if (_selectedItem.CollectionType == ProgramScheduleItemCollectionType.TelevisionShow) @if (_selectedItem.CollectionType == ProgramScheduleItemCollectionType.TelevisionShow)
{ {
<MudAutocomplete Class="mt-3" T="NamedMediaItemViewModel" Label="Television Show" <MudAutocomplete Class="mt-3" T="NamedMediaItemViewModel" Label="Television Show"
@bind-Value="_selectedItem.MediaItem" SearchFunc="@SearchTelevisionShows" @bind-Value="_selectedItem.MediaItem" SearchFunc="@SearchTelevisionShows"
ToStringFunc="@(c => c?.Name)" Placeholder="Type to search..."> ToStringFunc="@(c => c?.Name)" Placeholder="Type to search...">
<MoreItemsTemplate> <MoreItemsTemplate>
<MudText Align="Align.Center" Class="px-4 py-1"> <MudText Align="Align.Center" Class="px-4 py-1">
Only the first 10 items are shown Only the first 10 items are shown
</MudText> </MudText>
</MoreItemsTemplate> </MoreItemsTemplate>
</MudAutocomplete> </MudAutocomplete>
} }
@if (_selectedItem.CollectionType == ProgramScheduleItemCollectionType.TelevisionSeason) @if (_selectedItem.CollectionType == ProgramScheduleItemCollectionType.TelevisionSeason)
{ {
<MudAutocomplete Class="mt-3" T="NamedMediaItemViewModel" Label="Television Season" <MudAutocomplete Class="mt-3" T="NamedMediaItemViewModel" Label="Television Season"
@bind-Value="_selectedItem.MediaItem" SearchFunc="@SearchTelevisionSeasons" @bind-Value="_selectedItem.MediaItem" SearchFunc="@SearchTelevisionSeasons"
ToStringFunc="@(c => c?.Name)" Placeholder="Type to search..." ToStringFunc="@(c => c?.Name)" Placeholder="Type to search..."
MaxItems="20"> MaxItems="20">
<MoreItemsTemplate> <MoreItemsTemplate>
<MudText Align="Align.Center" Class="px-4 py-1"> <MudText Align="Align.Center" Class="px-4 py-1">
Only the first 20 items are shown Only the first 20 items are shown
</MudText> </MudText>
</MoreItemsTemplate> </MoreItemsTemplate>
</MudAutocomplete> </MudAutocomplete>
} }
@if (_selectedItem.CollectionType == ProgramScheduleItemCollectionType.Artist) @if (_selectedItem.CollectionType == ProgramScheduleItemCollectionType.Artist)
{ {
<MudAutocomplete Class="mt-3" T="NamedMediaItemViewModel" Label="Artist" <MudAutocomplete Class="mt-3" T="NamedMediaItemViewModel" Label="Artist"
@bind-Value="_selectedItem.MediaItem" SearchFunc="@SearchArtists" @bind-Value="_selectedItem.MediaItem" SearchFunc="@SearchArtists"
ToStringFunc="@(c => c?.Name)" Placeholder="Type to search..." ToStringFunc="@(c => c?.Name)" Placeholder="Type to search..."
MaxItems="10"> MaxItems="10">
<MoreItemsTemplate> <MoreItemsTemplate>
<MudText Align="Align.Center" Class="px-4 py-1"> <MudText Align="Align.Center" Class="px-4 py-1">
Only the first 10 items are shown Only the first 10 items are shown
</MudText> </MudText>
</MoreItemsTemplate> </MoreItemsTemplate>
</MudAutocomplete> </MudAutocomplete>
} }
<MudSelect Class="mt-3" Label="Playback Order" @bind-Value="@_selectedItem.PlaybackOrder" For="@(() => _selectedItem.PlaybackOrder)"> <MudSelect Class="mt-3" Label="Playback Order" @bind-Value="@_selectedItem.PlaybackOrder" For="@(() => _selectedItem.PlaybackOrder)">
@switch (_selectedItem.CollectionType) @switch (_selectedItem.CollectionType)
{ {
case ProgramScheduleItemCollectionType.MultiCollection: case ProgramScheduleItemCollectionType.MultiCollection:
<MudSelectItem Value="PlaybackOrder.Shuffle">Shuffle</MudSelectItem> <MudSelectItem Value="PlaybackOrder.Shuffle">Shuffle</MudSelectItem>
<MudSelectItem Value="PlaybackOrder.ShuffleInOrder">Shuffle In Order</MudSelectItem> <MudSelectItem Value="PlaybackOrder.ShuffleInOrder">Shuffle In Order</MudSelectItem>
break; break;
case ProgramScheduleItemCollectionType.Collection: case ProgramScheduleItemCollectionType.Collection:
case ProgramScheduleItemCollectionType.SmartCollection: case ProgramScheduleItemCollectionType.SmartCollection:
<MudSelectItem Value="PlaybackOrder.Chronological">Chronological</MudSelectItem> <MudSelectItem Value="PlaybackOrder.Chronological">Chronological</MudSelectItem>
<MudSelectItem Value="PlaybackOrder.Random">Random</MudSelectItem> <MudSelectItem Value="PlaybackOrder.Random">Random</MudSelectItem>
<MudSelectItem Value="PlaybackOrder.Shuffle">Shuffle</MudSelectItem> <MudSelectItem Value="PlaybackOrder.Shuffle">Shuffle</MudSelectItem>
<MudSelectItem Value="PlaybackOrder.ShuffleInOrder">Shuffle In Order</MudSelectItem> <MudSelectItem Value="PlaybackOrder.ShuffleInOrder">Shuffle In Order</MudSelectItem>
break; break;
case ProgramScheduleItemCollectionType.TelevisionShow: case ProgramScheduleItemCollectionType.TelevisionShow:
<MudSelectItem Value="PlaybackOrder.Chronological">Chronological</MudSelectItem> <MudSelectItem Value="PlaybackOrder.Chronological">Chronological</MudSelectItem>
<MudSelectItem Value="PlaybackOrder.SeasonEpisode">Season, Episode</MudSelectItem> <MudSelectItem Value="PlaybackOrder.SeasonEpisode">Season, Episode</MudSelectItem>
<MudSelectItem Value="PlaybackOrder.Random">Random</MudSelectItem> <MudSelectItem Value="PlaybackOrder.Random">Random</MudSelectItem>
<MudSelectItem Value="PlaybackOrder.Shuffle">Shuffle</MudSelectItem> <MudSelectItem Value="PlaybackOrder.Shuffle">Shuffle</MudSelectItem>
<MudSelectItem Value="PlaybackOrder.MultiEpisodeShuffle">Multi-Episode Shuffle</MudSelectItem> <MudSelectItem Value="PlaybackOrder.MultiEpisodeShuffle">Multi-Episode Shuffle</MudSelectItem>
break; break;
default: default:
<MudSelectItem Value="PlaybackOrder.Chronological">Chronological</MudSelectItem> <MudSelectItem Value="PlaybackOrder.Chronological">Chronological</MudSelectItem>
<MudSelectItem Value="PlaybackOrder.Random">Random</MudSelectItem> <MudSelectItem Value="PlaybackOrder.Random">Random</MudSelectItem>
<MudSelectItem Value="PlaybackOrder.Shuffle">Shuffle</MudSelectItem> <MudSelectItem Value="PlaybackOrder.Shuffle">Shuffle</MudSelectItem>
break; break;
} }
</MudSelect> </MudSelect>
<MudSelect Class="mt-3" Label="Playout Mode" @bind-Value="@_selectedItem.PlayoutMode" For="@(() => _selectedItem.PlayoutMode)"> <MudSelect Class="mt-3" Label="Playout Mode" @bind-Value="@_selectedItem.PlayoutMode" For="@(() => _selectedItem.PlayoutMode)">
@if (!_schedule.ShuffleScheduleItems) @if (!_schedule.ShuffleScheduleItems)
{ {
<MudSelectItem Value="PlayoutMode.Flood">Flood</MudSelectItem> <MudSelectItem Value="PlayoutMode.Flood">Flood</MudSelectItem>
} }
<MudSelectItem Value="PlayoutMode.One">One</MudSelectItem> <MudSelectItem Value="PlayoutMode.One">One</MudSelectItem>
<MudSelectItem Value="PlayoutMode.Multiple">Multiple</MudSelectItem> <MudSelectItem Value="PlayoutMode.Multiple">Multiple</MudSelectItem>
<MudSelectItem Value="PlayoutMode.Duration">Duration</MudSelectItem> <MudSelectItem Value="PlayoutMode.Duration">Duration</MudSelectItem>
</MudSelect> </MudSelect>
<MudTextField Class="mt-3" Label="Multiple Count" @bind-Value="@_selectedItem.MultipleCount" For="@(() => _selectedItem.MultipleCount)" Disabled="@(_selectedItem.PlayoutMode != PlayoutMode.Multiple)"/> <MudTextField Class="mt-3" Label="Multiple Count" @bind-Value="@_selectedItem.MultipleCount" For="@(() => _selectedItem.MultipleCount)" Disabled="@(_selectedItem.PlayoutMode != PlayoutMode.Multiple)"/>
<MudTimePicker Class="mt-3" Label="Playout Duration" @bind-Time="@_selectedItem.PlayoutDuration" For="@(() => _selectedItem.PlayoutDuration)" Disabled="@(_selectedItem.PlayoutMode != PlayoutMode.Duration)"/> <MudTimePicker Class="mt-3" Label="Playout Duration" @bind-Time="@_selectedItem.PlayoutDuration" For="@(() => _selectedItem.PlayoutDuration)" Disabled="@(_selectedItem.PlayoutMode != PlayoutMode.Duration)"/>
<MudSelect Class="mt-3" Label="Tail Mode" @bind-Value="@_selectedItem.TailMode" For="@(() => _selectedItem.TailMode)" Disabled="@(_selectedItem.PlayoutMode != PlayoutMode.Duration)"> <MudSelect Class="mt-3" Label="Tail Mode" @bind-Value="@_selectedItem.TailMode" For="@(() => _selectedItem.TailMode)" Disabled="@(_selectedItem.PlayoutMode != PlayoutMode.Duration)">
<MudSelectItem Value="@TailMode.None">(none)</MudSelectItem> <MudSelectItem Value="@TailMode.None">(none)</MudSelectItem>
<MudSelectItem Value="@TailMode.Offline">Offline</MudSelectItem> <MudSelectItem Value="@TailMode.Offline">Offline</MudSelectItem>
<MudSelectItem Value="@TailMode.Filler">Filler</MudSelectItem> <MudSelectItem Value="@TailMode.Filler">Filler</MudSelectItem>
</MudSelect> </MudSelect>
<MudTextField Class="mt-3" Label="Discard To Fill Attempts" @bind-Value="@_selectedItem.DiscardToFillAttempts" For="@(() => _selectedItem.DiscardToFillAttempts)" Disabled="@(_selectedItem.PlayoutMode != PlayoutMode.Duration)"/> <MudTextField Class="mt-3" Label="Discard To Fill Attempts" @bind-Value="@_selectedItem.DiscardToFillAttempts" For="@(() => _selectedItem.DiscardToFillAttempts)" Disabled="@(_selectedItem.PlayoutMode != PlayoutMode.Duration)"/>
<MudTextField Class="mt-3" Label="Custom Title" @bind-Value="@_selectedItem.CustomTitle" For="@(() => _selectedItem.CustomTitle)"/> <MudTextField Class="mt-3" Label="Custom Title" @bind-Value="@_selectedItem.CustomTitle" For="@(() => _selectedItem.CustomTitle)"/>
<MudSelect Class="mt-3" Label="Guide Mode" @bind-Value="@_selectedItem.GuideMode" For="@(() => _selectedItem.GuideMode)"> <MudSelect Class="mt-3" Label="Guide Mode" @bind-Value="@_selectedItem.GuideMode" For="@(() => _selectedItem.GuideMode)">
<MudSelectItem Value="@GuideMode.Normal">Normal</MudSelectItem> <MudSelectItem Value="@GuideMode.Normal">Normal</MudSelectItem>
<MudSelectItem Value="@GuideMode.Filler">Filler</MudSelectItem> <MudSelectItem Value="@GuideMode.Filler">Filler</MudSelectItem>
</MudSelect> </MudSelect>
</MudCardContent> </MudCardContent>
</MudCard> </MudCard>
</div> </div>
<div style="flex-grow: 1; max-width: 400px;"> <div style="flex-grow: 1; max-width: 400px;">
<MudCard> <MudCard>
<MudCardContent> <MudCardContent>
<MudSelect T="FillerPresetViewModel" <MudSelect T="FillerPresetViewModel"
Label="Pre-Roll Filler" Label="Pre-Roll Filler"
@bind-value="_selectedItem.PreRollFiller" @bind-value="_selectedItem.PreRollFiller"
Clearable="true"> Clearable="true">
@foreach (FillerPresetViewModel filler in _fillerPresets.Where(f => f.FillerKind == FillerKind.PreRoll)) @foreach (FillerPresetViewModel filler in _fillerPresets.Where(f => f.FillerKind == FillerKind.PreRoll))
{ {
<MudSelectItem Value="@filler">@filler.Name</MudSelectItem> <MudSelectItem Value="@filler">@filler.Name</MudSelectItem>
} }
</MudSelect> </MudSelect>
<MudSelect Class="mt-3" <MudSelect Class="mt-3"
T="FillerPresetViewModel" T="FillerPresetViewModel"
Label="Mid-Roll Filler" Label="Mid-Roll Filler"
@bind-value="_selectedItem.MidRollFiller" @bind-value="_selectedItem.MidRollFiller"
Clearable="true"> Clearable="true">
@foreach (FillerPresetViewModel filler in _fillerPresets.Where(f => f.FillerKind == FillerKind.MidRoll)) @foreach (FillerPresetViewModel filler in _fillerPresets.Where(f => f.FillerKind == FillerKind.MidRoll))
{ {
<MudSelectItem Value="@filler">@filler.Name</MudSelectItem> <MudSelectItem Value="@filler">@filler.Name</MudSelectItem>
} }
</MudSelect> </MudSelect>
<MudSelect Class="mt-3" <MudSelect Class="mt-3"
T="FillerPresetViewModel" T="FillerPresetViewModel"
Label="Post-Roll Filler" Label="Post-Roll Filler"
@bind-value="_selectedItem.PostRollFiller" @bind-value="_selectedItem.PostRollFiller"
Clearable="true"> Clearable="true">
@foreach (FillerPresetViewModel filler in _fillerPresets.Where(f => f.FillerKind == FillerKind.PostRoll)) @foreach (FillerPresetViewModel filler in _fillerPresets.Where(f => f.FillerKind == FillerKind.PostRoll))
{ {
<MudSelectItem Value="@filler">@filler.Name</MudSelectItem> <MudSelectItem Value="@filler">@filler.Name</MudSelectItem>
} }
</MudSelect> </MudSelect>
<MudSelect Class="mt-3" <MudSelect Class="mt-3"
T="FillerPresetViewModel" T="FillerPresetViewModel"
Label="Tail Filler" Label="Tail Filler"
@bind-value="_selectedItem.TailFiller" @bind-value="_selectedItem.TailFiller"
Clearable="true"> Clearable="true">
@foreach (FillerPresetViewModel filler in _fillerPresets.Where(f => f.FillerKind == FillerKind.Tail)) @foreach (FillerPresetViewModel filler in _fillerPresets.Where(f => f.FillerKind == FillerKind.Tail))
{ {
<MudSelectItem Value="@filler">@filler.Name</MudSelectItem> <MudSelectItem Value="@filler">@filler.Name</MudSelectItem>
} }
</MudSelect> </MudSelect>
<MudSelect Class="mt-3" <MudSelect Class="mt-3"
T="FillerPresetViewModel" T="FillerPresetViewModel"
Label="Fallback Filler" Label="Fallback Filler"
@bind-value="_selectedItem.FallbackFiller" @bind-value="_selectedItem.FallbackFiller"
Clearable="true"> Clearable="true">
@foreach (FillerPresetViewModel filler in _fillerPresets.Where(f => f.FillerKind == FillerKind.Fallback)) @foreach (FillerPresetViewModel filler in _fillerPresets.Where(f => f.FillerKind == FillerKind.Fallback))
{ {
<MudSelectItem Value="@filler">@filler.Name</MudSelectItem> <MudSelectItem Value="@filler">@filler.Name</MudSelectItem>
} }
</MudSelect> </MudSelect>
</MudCardContent> </MudCardContent>
</MudCard> </MudCard>
<MudCard Class="mt-4"> <MudCard Class="mt-4">
<MudCardContent> <MudCardContent>
<MudSelect Label="Watermark" @bind-Value="@_selectedItem.Watermark" For="@(() => _selectedItem.Watermark)" Clearable="true"> <MudSelect Label="Watermark" @bind-Value="@_selectedItem.Watermark" For="@(() => _selectedItem.Watermark)" Clearable="true">
@foreach (WatermarkViewModel watermark in _watermarks) @foreach (WatermarkViewModel watermark in _watermarks)
{ {
<MudSelectItem Value="@watermark">@watermark.Name</MudSelectItem> <MudSelectItem Value="@watermark">@watermark.Name</MudSelectItem>
} }
</MudSelect> </MudSelect>
<MudSelect Class="mt-3" <MudSelect Class="mt-3"
Label="Preferred Audio Language" Label="Preferred Audio Language"
@bind-Value="_selectedItem.PreferredAudioLanguageCode" @bind-Value="_selectedItem.PreferredAudioLanguageCode"
For="@(() => _selectedItem.PreferredAudioLanguageCode)" For="@(() => _selectedItem.PreferredAudioLanguageCode)"
Clearable="true"> Clearable="true">
<MudSelectItem Value="@((string)null)">(none)</MudSelectItem> <MudSelectItem Value="@((string)null)">(none)</MudSelectItem>
@foreach (CultureInfo culture in _availableCultures) @foreach (CultureInfo culture in _availableCultures)
{ {
<MudSelectItem Value="@culture.ThreeLetterISOLanguageName">@culture.EnglishName</MudSelectItem> <MudSelectItem Value="@culture.ThreeLetterISOLanguageName">@culture.EnglishName</MudSelectItem>
} }
</MudSelect> </MudSelect>
<MudTextField Class="mt-3" Label="Preferred Audio Title" @bind-Value="@_selectedItem.PreferredAudioTitle" For="@(() => _selectedItem.PreferredAudioTitle)"/> <MudTextField Class="mt-3" Label="Preferred Audio Title" @bind-Value="@_selectedItem.PreferredAudioTitle" For="@(() => _selectedItem.PreferredAudioTitle)"/>
<MudSelect Class="mt-3" <MudSelect Class="mt-3"
Label="Preferred Subtitle Language" Label="Preferred Subtitle Language"
@bind-Value="_selectedItem.PreferredSubtitleLanguageCode" @bind-Value="_selectedItem.PreferredSubtitleLanguageCode"
For="@(() => _selectedItem.PreferredSubtitleLanguageCode)" For="@(() => _selectedItem.PreferredSubtitleLanguageCode)"
Clearable="true"> Clearable="true">
<MudSelectItem Value="@((string)null)">(none)</MudSelectItem> <MudSelectItem Value="@((string)null)">(none)</MudSelectItem>
@foreach (CultureInfo culture in _availableCultures) @foreach (CultureInfo culture in _availableCultures)
{ {
<MudSelectItem Value="@culture.ThreeLetterISOLanguageName">@culture.EnglishName</MudSelectItem> <MudSelectItem Value="@culture.ThreeLetterISOLanguageName">@culture.EnglishName</MudSelectItem>
} }
</MudSelect> </MudSelect>
<MudSelect T="ChannelSubtitleMode?" Class="mt-3" Label="Subtitle Mode" @bind-Value="_selectedItem.SubtitleMode" For="@(() => _selectedItem.SubtitleMode)" Clearable="true"> <MudSelect T="ChannelSubtitleMode?" Class="mt-3" Label="Subtitle Mode" @bind-Value="_selectedItem.SubtitleMode" For="@(() => _selectedItem.SubtitleMode)" Clearable="true">
<MudSelectItem T="ChannelSubtitleMode?" Value="@(ChannelSubtitleMode.None)">None</MudSelectItem> <MudSelectItem T="ChannelSubtitleMode?" Value="@(ChannelSubtitleMode.None)">None</MudSelectItem>
<MudSelectItem T="ChannelSubtitleMode?" Value="@(ChannelSubtitleMode.Forced)">Forced</MudSelectItem> <MudSelectItem T="ChannelSubtitleMode?" Value="@(ChannelSubtitleMode.Forced)">Forced</MudSelectItem>
<MudSelectItem T="ChannelSubtitleMode?" Value="@(ChannelSubtitleMode.Default)">Default</MudSelectItem> <MudSelectItem T="ChannelSubtitleMode?" Value="@(ChannelSubtitleMode.Default)">Default</MudSelectItem>
<MudSelectItem T="ChannelSubtitleMode?" Value="@(ChannelSubtitleMode.Any)">Any</MudSelectItem> <MudSelectItem T="ChannelSubtitleMode?" Value="@(ChannelSubtitleMode.Any)">Any</MudSelectItem>
</MudSelect> </MudSelect>
</MudCardContent> </MudCardContent>
</MudCard> </MudCard>
</div> </div>
</div> </div>
</EditForm> </EditForm>
} }
</MudContainer> </MudContainer>

424
ErsatzTV/Pages/Search.razor

@ -92,226 +92,226 @@
</div> </div>
</MudPaper> </MudPaper>
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Style="margin-top: 96px"> <MudContainer MaxWidth="MaxWidth.ExtraLarge" Style="margin-top: 96px">
@if (_movies?.Count > 0) @if (_movies?.Count > 0)
{ {
<div class="mb-4" style="align-items: baseline; display: flex; flex-direction: row;"> <div class="mb-4" style="align-items: baseline; display: flex; flex-direction: row;">
<MudText Typo="Typo.h4" <MudText Typo="Typo.h4"
Style="scroll-margin-top: 160px" Style="scroll-margin-top: 160px"
UserAttributes="@(new Dictionary<string, object> { { "id", "movies" } })"> UserAttributes="@(new Dictionary<string, object> { { "id", "movies" } })">
Movies Movies
</MudText> </MudText>
@if (_movies.Count > 50) @if (_movies.Count > 50)
{ {
<MudLink Href="@GetMoviesLink()" Class="ml-4">See All >></MudLink> <MudLink Href="@GetMoviesLink()" Class="ml-4">See All >></MudLink>
} }
</div> </div>
<MudContainer MaxWidth="MaxWidth.False" Class="media-card-grid"> <MudContainer MaxWidth="MaxWidth.False" Class="media-card-grid">
@foreach (MovieCardViewModel card in _movies.Cards.OrderBy(m => m.SortTitle)) @foreach (MovieCardViewModel card in _movies.Cards.OrderBy(m => m.SortTitle))
{ {
<MediaCard Data="@card" <MediaCard Data="@card"
Link="@($"media/movies/{card.MovieId}")" Link="@($"media/movies/{card.MovieId}")"
AddToCollectionClicked="@AddToCollection" AddToCollectionClicked="@AddToCollection"
SelectClicked="@(e => SelectClicked(card, e))" SelectClicked="@(e => SelectClicked(card, e))"
IsSelected="@IsSelected(card)" IsSelected="@IsSelected(card)"
IsSelectMode="@IsSelectMode()"/> IsSelectMode="@IsSelectMode()"/>
} }
</MudContainer> </MudContainer>
} }
@if (_shows?.Count > 0)
{
<div class="mb-4" style="align-items: baseline; display: flex; flex-direction: row;">
<MudText Typo="Typo.h4"
Style="scroll-margin-top: 160px"
UserAttributes="@(new Dictionary<string, object> { { "id", "shows" } })">
Shows
</MudText>
@if (_shows.Count > 50)
{
<MudLink Href="@GetShowsLink()" Class="ml-4">See All >></MudLink>
}
</div>
<MudContainer MaxWidth="MaxWidth.False" Class="media-card-grid"> @if (_shows?.Count > 0)
@foreach (TelevisionShowCardViewModel card in _shows.Cards.OrderBy(s => s.SortTitle)) {
{ <div class="mb-4" style="align-items: baseline; display: flex; flex-direction: row;">
<MediaCard Data="@card" <MudText Typo="Typo.h4"
Link="@($"media/tv/shows/{card.TelevisionShowId}")" Style="scroll-margin-top: 160px"
AddToCollectionClicked="@AddToCollection" UserAttributes="@(new Dictionary<string, object> { { "id", "shows" } })">
SelectClicked="@(e => SelectClicked(card, e))" Shows
IsSelected="@IsSelected(card)" </MudText>
IsSelectMode="@IsSelectMode()"/> @if (_shows.Count > 50)
} {
</MudContainer> <MudLink Href="@GetShowsLink()" Class="ml-4">See All >></MudLink>
} }
</div>
@if (_seasons?.Count > 0)
{
<div class="mb-4" style="align-items: baseline; display: flex; flex-direction: row;">
<MudText Typo="Typo.h4"
Style="scroll-margin-top: 160px"
UserAttributes="@(new Dictionary<string, object> { { "id", "seasons" } })">
Seasons
</MudText>
@if (_seasons.Count > 50)
{
<MudLink Href="@GetSeasonsLink()" Class="ml-4">See All >></MudLink>
}
</div>
<MudContainer MaxWidth="MaxWidth.False" Class="media-card-grid"> <MudContainer MaxWidth="MaxWidth.False" Class="media-card-grid">
@foreach (TelevisionSeasonCardViewModel card in _seasons.Cards.OrderBy(s => s.SortTitle)) @foreach (TelevisionShowCardViewModel card in _shows.Cards.OrderBy(s => s.SortTitle))
{ {
<MediaCard Data="@card" <MediaCard Data="@card"
Link="@($"media/tv/seasons/{card.TelevisionSeasonId}")" Link="@($"media/tv/shows/{card.TelevisionShowId}")"
AddToCollectionClicked="@AddToCollection" AddToCollectionClicked="@AddToCollection"
SelectClicked="@(e => SelectClicked(card, e))" SelectClicked="@(e => SelectClicked(card, e))"
IsSelected="@IsSelected(card)" IsSelected="@IsSelected(card)"
IsSelectMode="@IsSelectMode()"/> IsSelectMode="@IsSelectMode()"/>
} }
</MudContainer> </MudContainer>
} }
@if (_episodes?.Count > 0)
{
<div class="mb-4" style="align-items: baseline; display: flex; flex-direction: row;">
<MudText Typo="Typo.h4"
Style="scroll-margin-top: 160px"
UserAttributes="@(new Dictionary<string, object> { { "id", "episodes" } })">
Episodes
</MudText>
@if (_episodes.Count > 50)
{
<MudLink Href="@GetEpisodesLink()" Class="ml-4">See All >></MudLink>
}
</div>
<MudContainer MaxWidth="MaxWidth.False" Class="media-card-grid"> @if (_seasons?.Count > 0)
@foreach (TelevisionEpisodeCardViewModel card in _episodes.Cards.OrderBy(s => s.SortTitle)) {
{ <div class="mb-4" style="align-items: baseline; display: flex; flex-direction: row;">
<MediaCard Data="@card" <MudText Typo="Typo.h4"
AddToCollectionClicked="@AddToCollection" Style="scroll-margin-top: 160px"
Link="@($"media/tv/seasons/{card.SeasonId}#episode-{card.EpisodeId}")" UserAttributes="@(new Dictionary<string, object> { { "id", "seasons" } })">
Subtitle="@($"{card.ShowTitle} - S{card.Season} E{card.Episode}")" Seasons
SelectClicked="@(e => SelectClicked(card, e))" </MudText>
IsSelected="@IsSelected(card)" @if (_seasons.Count > 50)
IsSelectMode="@IsSelectMode()"/> {
} <MudLink Href="@GetSeasonsLink()" Class="ml-4">See All >></MudLink>
</MudContainer> }
} </div>
@if (_artists?.Count > 0)
{
<div class="mb-4" style="align-items: baseline; display: flex; flex-direction: row;">
<MudText Typo="Typo.h4"
Style="scroll-margin-top: 160px"
UserAttributes="@(new Dictionary<string, object> { { "id", "artists" } })">
Artists
</MudText>
@if (_artists.Count > 50)
{
<MudLink Href="@GetArtistsLink()" Class="ml-4">See All >></MudLink>
}
</div>
<MudContainer MaxWidth="MaxWidth.False" Class="media-card-grid"> <MudContainer MaxWidth="MaxWidth.False" Class="media-card-grid">
@foreach (ArtistCardViewModel card in _artists.Cards.OrderBy(s => s.SortTitle)) @foreach (TelevisionSeasonCardViewModel card in _seasons.Cards.OrderBy(s => s.SortTitle))
{ {
<MediaCard Data="@card" <MediaCard Data="@card"
Link="@($"media/music/artists/{card.ArtistId}")" Link="@($"media/tv/seasons/{card.TelevisionSeasonId}")"
ArtworkKind="ArtworkKind.Thumbnail" AddToCollectionClicked="@AddToCollection"
AddToCollectionClicked="@AddToCollection" SelectClicked="@(e => SelectClicked(card, e))"
SelectClicked="@(e => SelectClicked(card, e))" IsSelected="@IsSelected(card)"
IsSelected="@IsSelected(card)" IsSelectMode="@IsSelectMode()"/>
IsSelectMode="@IsSelectMode()"/> }
} </MudContainer>
</MudContainer> }
}
@if (_musicVideos?.Count > 0)
{
<div class="mb-4" style="align-items: baseline; display: flex; flex-direction: row;">
<MudText Typo="Typo.h4"
Style="scroll-margin-top: 160px"
UserAttributes="@(new Dictionary<string, object> { { "id", "music_videos" } })">
Music Videos
</MudText>
@if (_musicVideos.Count > 50)
{
<MudLink Href="@GetMusicVideosLink()" Class="ml-4">See All >></MudLink>
}
</div>
<MudContainer MaxWidth="MaxWidth.False" Class="media-card-grid"> @if (_episodes?.Count > 0)
@foreach (MusicVideoCardViewModel card in _musicVideos.Cards.OrderBy(s => s.SortTitle)) {
{ <div class="mb-4" style="align-items: baseline; display: flex; flex-direction: row;">
<MediaCard Data="@card" <MudText Typo="Typo.h4"
Link="" Style="scroll-margin-top: 160px"
ArtworkKind="ArtworkKind.Thumbnail" UserAttributes="@(new Dictionary<string, object> { { "id", "episodes" } })">
AddToCollectionClicked="@AddToCollection" Episodes
SelectClicked="@(e => SelectClicked(card, e))" </MudText>
IsSelected="@IsSelected(card)" @if (_episodes.Count > 50)
IsSelectMode="@IsSelectMode()"/> {
} <MudLink Href="@GetEpisodesLink()" Class="ml-4">See All >></MudLink>
</MudContainer> }
} </div>
@if (_otherVideos?.Count > 0)
{
<div class="mb-4" style="align-items: baseline; display: flex; flex-direction: row;">
<MudText Typo="Typo.h4"
Style="scroll-margin-top: 160px"
UserAttributes="@(new Dictionary<string, object> { { "id", "other_videos" } })">
Other Videos
</MudText>
@if (_otherVideos.Count > 50)
{
<MudLink Href="@GetOtherVideosLink()" Class="ml-4">See All >></MudLink>
}
</div>
<MudContainer MaxWidth="MaxWidth.False" Class="media-card-grid"> <MudContainer MaxWidth="MaxWidth.False" Class="media-card-grid">
@foreach (OtherVideoCardViewModel card in _otherVideos.Cards.OrderBy(s => s.SortTitle)) @foreach (TelevisionEpisodeCardViewModel card in _episodes.Cards.OrderBy(s => s.SortTitle))
{ {
<MediaCard Data="@card" <MediaCard Data="@card"
Link="" AddToCollectionClicked="@AddToCollection"
ArtworkKind="ArtworkKind.Thumbnail" Link="@($"media/tv/seasons/{card.SeasonId}#episode-{card.EpisodeId}")"
AddToCollectionClicked="@AddToCollection" Subtitle="@($"{card.ShowTitle} - S{card.Season} E{card.Episode}")"
SelectClicked="@(e => SelectClicked(card, e))" SelectClicked="@(e => SelectClicked(card, e))"
IsSelected="@IsSelected(card)" IsSelected="@IsSelected(card)"
IsSelectMode="@IsSelectMode()"/> IsSelectMode="@IsSelectMode()"/>
} }
</MudContainer> </MudContainer>
} }
@if (_songs?.Count > 0)
{
<div class="mb-4" style="align-items: baseline; display: flex; flex-direction: row;">
<MudText Typo="Typo.h4"
Style="scroll-margin-top: 160px"
UserAttributes="@(new Dictionary<string, object> { { "id", "songs" } })">
Songs
</MudText>
@if (_songs.Count > 50)
{
<MudLink Href="@GetSongsLink()" Class="ml-4">See All >></MudLink>
}
</div>
<MudContainer MaxWidth="MaxWidth.False" Class="media-card-grid"> @if (_artists?.Count > 0)
@foreach (SongCardViewModel card in _songs.Cards.OrderBy(s => s.SortTitle)) {
{ <div class="mb-4" style="align-items: baseline; display: flex; flex-direction: row;">
<MediaCard Data="@card" <MudText Typo="Typo.h4"
Link="" Style="scroll-margin-top: 160px"
ArtworkKind="ArtworkKind.Thumbnail" UserAttributes="@(new Dictionary<string, object> { { "id", "artists" } })">
AddToCollectionClicked="@AddToCollection" Artists
SelectClicked="@(e => SelectClicked(card, e))" </MudText>
IsSelected="@IsSelected(card)" @if (_artists.Count > 50)
IsSelectMode="@IsSelectMode()"/> {
} <MudLink Href="@GetArtistsLink()" Class="ml-4">See All >></MudLink>
</MudContainer> }
} </div>
<MudContainer MaxWidth="MaxWidth.False" Class="media-card-grid">
@foreach (ArtistCardViewModel card in _artists.Cards.OrderBy(s => s.SortTitle))
{
<MediaCard Data="@card"
Link="@($"media/music/artists/{card.ArtistId}")"
ArtworkKind="ArtworkKind.Thumbnail"
AddToCollectionClicked="@AddToCollection"
SelectClicked="@(e => SelectClicked(card, e))"
IsSelected="@IsSelected(card)"
IsSelectMode="@IsSelectMode()"/>
}
</MudContainer>
}
@if (_musicVideos?.Count > 0)
{
<div class="mb-4" style="align-items: baseline; display: flex; flex-direction: row;">
<MudText Typo="Typo.h4"
Style="scroll-margin-top: 160px"
UserAttributes="@(new Dictionary<string, object> { { "id", "music_videos" } })">
Music Videos
</MudText>
@if (_musicVideos.Count > 50)
{
<MudLink Href="@GetMusicVideosLink()" Class="ml-4">See All >></MudLink>
}
</div>
<MudContainer MaxWidth="MaxWidth.False" Class="media-card-grid">
@foreach (MusicVideoCardViewModel card in _musicVideos.Cards.OrderBy(s => s.SortTitle))
{
<MediaCard Data="@card"
Link=""
ArtworkKind="ArtworkKind.Thumbnail"
AddToCollectionClicked="@AddToCollection"
SelectClicked="@(e => SelectClicked(card, e))"
IsSelected="@IsSelected(card)"
IsSelectMode="@IsSelectMode()"/>
}
</MudContainer>
}
@if (_otherVideos?.Count > 0)
{
<div class="mb-4" style="align-items: baseline; display: flex; flex-direction: row;">
<MudText Typo="Typo.h4"
Style="scroll-margin-top: 160px"
UserAttributes="@(new Dictionary<string, object> { { "id", "other_videos" } })">
Other Videos
</MudText>
@if (_otherVideos.Count > 50)
{
<MudLink Href="@GetOtherVideosLink()" Class="ml-4">See All >></MudLink>
}
</div>
<MudContainer MaxWidth="MaxWidth.False" Class="media-card-grid">
@foreach (OtherVideoCardViewModel card in _otherVideos.Cards.OrderBy(s => s.SortTitle))
{
<MediaCard Data="@card"
Link=""
ArtworkKind="ArtworkKind.Thumbnail"
AddToCollectionClicked="@AddToCollection"
SelectClicked="@(e => SelectClicked(card, e))"
IsSelected="@IsSelected(card)"
IsSelectMode="@IsSelectMode()"/>
}
</MudContainer>
}
@if (_songs?.Count > 0)
{
<div class="mb-4" style="align-items: baseline; display: flex; flex-direction: row;">
<MudText Typo="Typo.h4"
Style="scroll-margin-top: 160px"
UserAttributes="@(new Dictionary<string, object> { { "id", "songs" } })">
Songs
</MudText>
@if (_songs.Count > 50)
{
<MudLink Href="@GetSongsLink()" Class="ml-4">See All >></MudLink>
}
</div>
<MudContainer MaxWidth="MaxWidth.False" Class="media-card-grid">
@foreach (SongCardViewModel card in _songs.Cards.OrderBy(s => s.SortTitle))
{
<MediaCard Data="@card"
Link=""
ArtworkKind="ArtworkKind.Thumbnail"
AddToCollectionClicked="@AddToCollection"
SelectClicked="@(e => SelectClicked(card, e))"
IsSelected="@IsSelected(card)"
IsSelectMode="@IsSelectMode()"/>
}
</MudContainer>
}
</MudContainer> </MudContainer>
@code { @code {

458
ErsatzTV/Pages/Settings.razor

@ -3,10 +3,10 @@
@using ErsatzTV.Application.Filler @using ErsatzTV.Application.Filler
@using ErsatzTV.Application.HDHR @using ErsatzTV.Application.HDHR
@using ErsatzTV.Application.MediaItems @using ErsatzTV.Application.MediaItems
@using ErsatzTV.Application.Resolutions
@using ErsatzTV.Application.Watermarks @using ErsatzTV.Application.Watermarks
@using System.Globalization @using System.Globalization
@using ErsatzTV.Application.Configuration @using ErsatzTV.Application.Configuration
@using ErsatzTV.Application.Resolutions
@using ErsatzTV.Core.Domain.Filler @using ErsatzTV.Core.Domain.Filler
@using ErsatzTV.FFmpeg.OutputFormat @using ErsatzTV.FFmpeg.OutputFormat
@using Serilog.Events @using Serilog.Events
@ -17,232 +17,232 @@
@inject IDialogService Dialog @inject IDialogService Dialog
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="pt-8" Style="display: flex; flex-direction: row"> <MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="pt-8" Style="display: flex; flex-direction: row">
<MudGrid> <MudGrid>
<MudCard Class="mr-6 mb-6" Style="max-width: 400px"> <MudCard Class="mr-6 mb-6" Style="max-width: 400px">
<MudCardHeader> <MudCardHeader>
<CardHeaderContent> <CardHeaderContent>
<MudText Typo="Typo.h6">FFmpeg Settings</MudText> <MudText Typo="Typo.h6">FFmpeg Settings</MudText>
</CardHeaderContent> </CardHeaderContent>
</MudCardHeader> </MudCardHeader>
<MudCardContent> <MudCardContent>
<MudForm @bind-IsValid="@_success"> <MudForm @bind-IsValid="@_success">
<MudTextField T="string" Label="FFmpeg Path" @bind-Value="_ffmpegSettings.FFmpegPath" Validation="@(new Func<string, string>(ValidatePathExists))" Required="true" RequiredError="FFmpeg path is required!"/> <MudTextField T="string" Label="FFmpeg Path" @bind-Value="_ffmpegSettings.FFmpegPath" Validation="@(new Func<string, string>(ValidatePathExists))" Required="true" RequiredError="FFmpeg path is required!"/>
<MudElement HtmlTag="div" Class="mt-3"> <MudElement HtmlTag="div" Class="mt-3">
<MudTextField T="string" Label="FFprobe Path" @bind-Value="_ffmpegSettings.FFprobePath" Validation="@(new Func<string, string>(ValidatePathExists))" Required="true" RequiredError="FFprobe path is required!"/> <MudTextField T="string" Label="FFprobe Path" @bind-Value="_ffmpegSettings.FFprobePath" Validation="@(new Func<string, string>(ValidatePathExists))" Required="true" RequiredError="FFprobe path is required!"/>
</MudElement> </MudElement>
<MudElement HtmlTag="div" Class="mt-3"> <MudElement HtmlTag="div" Class="mt-3">
<MudSelect Label="Default Profile" @bind-Value="_ffmpegSettings.DefaultFFmpegProfileId" For="@(() => _ffmpegSettings.DefaultFFmpegProfileId)"> <MudSelect Label="Default Profile" @bind-Value="_ffmpegSettings.DefaultFFmpegProfileId" For="@(() => _ffmpegSettings.DefaultFFmpegProfileId)">
@foreach (FFmpegProfileViewModel profile in _ffmpegProfiles) @foreach (FFmpegProfileViewModel profile in _ffmpegProfiles)
{
<MudSelectItem Value="@profile.Id">@profile.Name</MudSelectItem>
}
</MudSelect>
</MudElement>
<MudSelect Class="mt-3" Label="Preferred Audio Language" @bind-Value="_ffmpegSettings.PreferredAudioLanguageCode" For="@(() => _ffmpegSettings.PreferredAudioLanguageCode)" Required="true" RequiredError="Preferred Language Code is required!">
@foreach (CultureInfo culture in _availableCultures)
{
<MudSelectItem Value="@culture.ThreeLetterISOLanguageName">@culture.EnglishName</MudSelectItem>
}
</MudSelect>
<MudElement HtmlTag="div" Class="mt-3">
<MudSwitch T="bool"
Label="Save troubleshooting reports to disk"
Color="Color.Primary"
@bind-Checked="@_ffmpegSettings.SaveReports"/>
</MudElement>
<MudSelect Class="mt-3"
Label="Global Watermark"
@bind-Value="_ffmpegSettings.GlobalWatermarkId"
For="@(() => _ffmpegSettings.GlobalWatermarkId)"
Clearable="true">
<MudSelectItem T="int?" Value="@((int?)null)">(none)</MudSelectItem>
@foreach (WatermarkViewModel watermark in _watermarks)
{
<MudSelectItem T="int?" Value="@watermark.Id">@watermark.Name</MudSelectItem>
}
</MudSelect>
<MudSelect Class="mt-3"
Label="Global Fallback Filler"
@bind-Value="_ffmpegSettings.GlobalFallbackFillerId"
For="@(() => _ffmpegSettings.GlobalFallbackFillerId)"
Clearable="true">
<MudSelectItem T="int?" Value="@((int?)null)">(none)</MudSelectItem>
@foreach (FillerPresetViewModel fillerPreset in _fillerPresets)
{
<MudSelectItem T="int?" Value="@fillerPreset.Id">@fillerPreset.Name</MudSelectItem>
}
</MudSelect>
<MudElement HtmlTag="div" Class="mt-3">
<MudTextField T="int"
Label="HLS Segmenter Idle Timeout"
@bind-Value="_ffmpegSettings.HlsSegmenterIdleTimeout"
Validation="@(new Func<int, string>(ValidateHlsSegmenterIdleTimeout))"
Required="true"
RequiredError="HLS Segmenter idle timeout is required!"
Adornment="Adornment.End"
AdornmentText="Seconds"/>
</MudElement>
<MudElement HtmlTag="div" Class="mt-3">
<MudTextField T="int"
Label="Work-Ahead HLS Segmenter Limit"
@bind-Value="_ffmpegSettings.WorkAheadSegmenterLimit"
Validation="@(new Func<int, string>(ValidateWorkAheadSegmenterLimit))"
Required="true"
RequiredError="Work-ahead HLS Segmenter limit is required!"/>
</MudElement>
<MudElement HtmlTag="div" Class="mt-3">
<MudTextField T="int"
Label="HLS Segmenter Initial Segment Count"
@bind-Value="_ffmpegSettings.InitialSegmentCount"
Validation="@(new Func<int, string>(ValidateInitialSegmentCount))"
Required="true"
RequiredError="HLS Segmenter initial segment count is required!"/>
</MudElement>
<MudSelect Class="mt-3"
Label="HLS Direct Output Format"
@bind-Value="_ffmpegSettings.HlsDirectOutputFormat"
For="@(() => _ffmpegSettings.HlsDirectOutputFormat)">
<MudSelectItem T="OutputFormatKind" Value="@OutputFormatKind.MpegTs">MPEG-TS</MudSelectItem>
<MudSelectItem T="OutputFormatKind" Value="@OutputFormatKind.Mp4">MP4</MudSelectItem>
<MudSelectItem T="OutputFormatKind" Value="@OutputFormatKind.Mkv">MKV</MudSelectItem>
</MudSelect>
</MudForm>
</MudCardContent>
<MudCardActions>
<MudButton Variant="Variant.Filled" Color="Color.Primary" Disabled="@(!_success)" OnClick="@(_ => SaveFFmpegSettings())">Save Settings</MudButton>
</MudCardActions>
</MudCard>
<MudStack Class="mr-6">
<MudCard Class="mb-6" Style="width: 350px">
<MudCardHeader>
<CardHeaderContent>
<MudText Typo="Typo.h6">General Settings</MudText>
</CardHeaderContent>
</MudCardHeader>
<MudCardContent>
<MudForm>
<MudSelect Class="mt-3"
Label="Minimum Log Level"
@bind-Value="_generalSettings.MinimumLogLevel"
For="@(() => _generalSettings.MinimumLogLevel)">
<MudSelectItem Value="@LogEventLevel.Debug">Debug</MudSelectItem>
<MudSelectItem Value="@LogEventLevel.Information">Information</MudSelectItem>
<MudSelectItem Value="@LogEventLevel.Warning">Warning</MudSelectItem>
<MudSelectItem Value="@LogEventLevel.Error">Error</MudSelectItem>
</MudSelect>
</MudForm>
</MudCardContent>
<MudCardActions>
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="@(_ => SaveGeneralSettings())">Save Settings</MudButton>
</MudCardActions>
</MudCard>
<MudCard Class="mb-6" Style="width: 350px">
<MudCardHeader>
<CardHeaderContent>
<MudText Typo="Typo.h6">HDHomeRun Settings</MudText>
</CardHeaderContent>
</MudCardHeader>
<MudCardContent>
<MudForm @bind-IsValid="@_hdhrSuccess">
<MudTextField T="int" Label="Tuner Count" @bind-Value="_tunerCount" Validation="@(new Func<int, string>(ValidateTunerCount))" Required="true" RequiredError="Tuner count is required!"/>
</MudForm>
</MudCardContent>
<MudCardActions>
<MudButton Variant="Variant.Filled" Color="Color.Primary" Disabled="@(!_hdhrSuccess)" OnClick="@(_ => SaveHDHRSettings())">Save Settings</MudButton>
</MudCardActions>
</MudCard>
<MudCard Class="mb-6" Style="width: 350px">
<MudCardHeader>
<CardHeaderContent>
<MudText Typo="Typo.h6">Scanner Settings</MudText>
</CardHeaderContent>
</MudCardHeader>
<MudCardContent>
<MudForm @bind-IsValid="@_scannerSuccess">
<MudTextField T="int"
Label="Library Refresh Interval"
@bind-Value="_libraryRefreshInterval"
Validation="@(new Func<int, string>(ValidateLibraryRefreshInterval))"
Required="true"
RequiredError="Library refresh interval is required!"
Adornment="Adornment.End"
AdornmentText="Hours"/>
</MudForm>
</MudCardContent>
<MudCardActions>
<MudButton Variant="Variant.Filled" Color="Color.Primary" Disabled="@(!_scannerSuccess)" OnClick="@(_ => SaveScannerSettings())">Save Settings</MudButton>
</MudCardActions>
</MudCard>
<MudCard Class="mb-6" Style="width: 350px">
<MudCardHeader>
<CardHeaderContent>
<MudText Typo="Typo.h6">Playout Settings</MudText>
</CardHeaderContent>
</MudCardHeader>
<MudCardContent>
<MudForm @bind-IsValid="@_playoutSuccess">
<MudTextField T="int"
Label="Days To Build"
@bind-Value="_playoutSettings.DaysToBuild"
Validation="@(new Func<int, string>(ValidatePlayoutDaysToBuild))"
Required="true"
RequiredError="Days to build is required!"
Adornment="Adornment.End"
AdornmentText="Days"/>
<MudElement HtmlTag="div" Class="mt-3">
<MudTooltip Text="Controls whether file-not-found or unavailable items should be included in playouts">
<MudCheckBox Label="Skip Missing Items"
@bind-Checked="_playoutSettings.SkipMissingItems"
For="@(() => _playoutSettings.SkipMissingItems)"/>
</MudTooltip>
</MudElement>
</MudForm>
</MudCardContent>
<MudCardActions>
<MudButton Variant="Variant.Filled" Color="Color.Primary" Disabled="@(!_playoutSuccess)" OnClick="@(_ => SavePlayoutSettings())">Save Settings</MudButton>
</MudCardActions>
</MudCard>
</MudStack>
<MudStack Class="mr-6">
<MudCard Class="mb-6" Style="width: 350px">
<MudCardHeader>
<CardHeaderContent>
<MudText Typo="Typo.h6">Custom Resolutions</MudText>
</CardHeaderContent>
</MudCardHeader>
<MudCardContent>
@if (_customResolutions.Any())
{ {
<MudTable Hover="true" Items="_customResolutions" Dense="true" Class="mt-6"> <MudSelectItem Value="@profile.Id">@profile.Name</MudSelectItem>
<ColGroup>
<col/>
<col style="width: 60px;"/>
</ColGroup>
<HeaderContent>
</HeaderContent>
<RowTemplate>
<MudTd DataLabel="Resolution">@context.Name</MudTd>
<MudTd>
<div style="align-items: center; display: flex;">
<MudTooltip Text="Delete Custom Resolution">
<MudIconButton Icon="@Icons.Material.Filled.Delete"
OnClick="@(() => DeleteCustomResolution(context))">
</MudIconButton>
</MudTooltip>
</div>
</MudTd>
</RowTemplate>
</MudTable>
} }
</MudCardContent> </MudSelect>
<MudCardActions> </MudElement>
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="@(_ => AddCustomResolution())" Class="ml-2"> <MudSelect Class="mt-3" Label="Preferred Audio Language" @bind-Value="_ffmpegSettings.PreferredAudioLanguageCode" For="@(() => _ffmpegSettings.PreferredAudioLanguageCode)" Required="true" RequiredError="Preferred Language Code is required!">
Add Custom Resolution @foreach (CultureInfo culture in _availableCultures)
</MudButton> {
</MudCardActions> <MudSelectItem Value="@culture.ThreeLetterISOLanguageName">@culture.EnglishName</MudSelectItem>
</MudCard> }
</MudStack> </MudSelect>
</MudGrid> <MudElement HtmlTag="div" Class="mt-3">
<MudSwitch T="bool"
Label="Save troubleshooting reports to disk"
Color="Color.Primary"
@bind-Checked="@_ffmpegSettings.SaveReports"/>
</MudElement>
<MudSelect Class="mt-3"
Label="Global Watermark"
@bind-Value="_ffmpegSettings.GlobalWatermarkId"
For="@(() => _ffmpegSettings.GlobalWatermarkId)"
Clearable="true">
<MudSelectItem T="int?" Value="@((int?)null)">(none)</MudSelectItem>
@foreach (WatermarkViewModel watermark in _watermarks)
{
<MudSelectItem T="int?" Value="@watermark.Id">@watermark.Name</MudSelectItem>
}
</MudSelect>
<MudSelect Class="mt-3"
Label="Global Fallback Filler"
@bind-Value="_ffmpegSettings.GlobalFallbackFillerId"
For="@(() => _ffmpegSettings.GlobalFallbackFillerId)"
Clearable="true">
<MudSelectItem T="int?" Value="@((int?)null)">(none)</MudSelectItem>
@foreach (FillerPresetViewModel fillerPreset in _fillerPresets)
{
<MudSelectItem T="int?" Value="@fillerPreset.Id">@fillerPreset.Name</MudSelectItem>
}
</MudSelect>
<MudElement HtmlTag="div" Class="mt-3">
<MudTextField T="int"
Label="HLS Segmenter Idle Timeout"
@bind-Value="_ffmpegSettings.HlsSegmenterIdleTimeout"
Validation="@(new Func<int, string>(ValidateHlsSegmenterIdleTimeout))"
Required="true"
RequiredError="HLS Segmenter idle timeout is required!"
Adornment="Adornment.End"
AdornmentText="Seconds"/>
</MudElement>
<MudElement HtmlTag="div" Class="mt-3">
<MudTextField T="int"
Label="Work-Ahead HLS Segmenter Limit"
@bind-Value="_ffmpegSettings.WorkAheadSegmenterLimit"
Validation="@(new Func<int, string>(ValidateWorkAheadSegmenterLimit))"
Required="true"
RequiredError="Work-ahead HLS Segmenter limit is required!"/>
</MudElement>
<MudElement HtmlTag="div" Class="mt-3">
<MudTextField T="int"
Label="HLS Segmenter Initial Segment Count"
@bind-Value="_ffmpegSettings.InitialSegmentCount"
Validation="@(new Func<int, string>(ValidateInitialSegmentCount))"
Required="true"
RequiredError="HLS Segmenter initial segment count is required!"/>
</MudElement>
<MudSelect Class="mt-3"
Label="HLS Direct Output Format"
@bind-Value="_ffmpegSettings.HlsDirectOutputFormat"
For="@(() => _ffmpegSettings.HlsDirectOutputFormat)">
<MudSelectItem T="OutputFormatKind" Value="@OutputFormatKind.MpegTs">MPEG-TS</MudSelectItem>
<MudSelectItem T="OutputFormatKind" Value="@OutputFormatKind.Mp4">MP4</MudSelectItem>
<MudSelectItem T="OutputFormatKind" Value="@OutputFormatKind.Mkv">MKV</MudSelectItem>
</MudSelect>
</MudForm>
</MudCardContent>
<MudCardActions>
<MudButton Variant="Variant.Filled" Color="Color.Primary" Disabled="@(!_success)" OnClick="@(_ => SaveFFmpegSettings())">Save Settings</MudButton>
</MudCardActions>
</MudCard>
<MudStack Class="mr-6">
<MudCard Class="mb-6" Style="width: 350px">
<MudCardHeader>
<CardHeaderContent>
<MudText Typo="Typo.h6">General Settings</MudText>
</CardHeaderContent>
</MudCardHeader>
<MudCardContent>
<MudForm>
<MudSelect Class="mt-3"
Label="Minimum Log Level"
@bind-Value="_generalSettings.MinimumLogLevel"
For="@(() => _generalSettings.MinimumLogLevel)">
<MudSelectItem Value="@LogEventLevel.Debug">Debug</MudSelectItem>
<MudSelectItem Value="@LogEventLevel.Information">Information</MudSelectItem>
<MudSelectItem Value="@LogEventLevel.Warning">Warning</MudSelectItem>
<MudSelectItem Value="@LogEventLevel.Error">Error</MudSelectItem>
</MudSelect>
</MudForm>
</MudCardContent>
<MudCardActions>
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="@(_ => SaveGeneralSettings())">Save Settings</MudButton>
</MudCardActions>
</MudCard>
<MudCard Class="mb-6" Style="width: 350px">
<MudCardHeader>
<CardHeaderContent>
<MudText Typo="Typo.h6">HDHomeRun Settings</MudText>
</CardHeaderContent>
</MudCardHeader>
<MudCardContent>
<MudForm @bind-IsValid="@_hdhrSuccess">
<MudTextField T="int" Label="Tuner Count" @bind-Value="_tunerCount" Validation="@(new Func<int, string>(ValidateTunerCount))" Required="true" RequiredError="Tuner count is required!"/>
</MudForm>
</MudCardContent>
<MudCardActions>
<MudButton Variant="Variant.Filled" Color="Color.Primary" Disabled="@(!_hdhrSuccess)" OnClick="@(_ => SaveHDHRSettings())">Save Settings</MudButton>
</MudCardActions>
</MudCard>
<MudCard Class="mb-6" Style="width: 350px">
<MudCardHeader>
<CardHeaderContent>
<MudText Typo="Typo.h6">Scanner Settings</MudText>
</CardHeaderContent>
</MudCardHeader>
<MudCardContent>
<MudForm @bind-IsValid="@_scannerSuccess">
<MudTextField T="int"
Label="Library Refresh Interval"
@bind-Value="_libraryRefreshInterval"
Validation="@(new Func<int, string>(ValidateLibraryRefreshInterval))"
Required="true"
RequiredError="Library refresh interval is required!"
Adornment="Adornment.End"
AdornmentText="Hours"/>
</MudForm>
</MudCardContent>
<MudCardActions>
<MudButton Variant="Variant.Filled" Color="Color.Primary" Disabled="@(!_scannerSuccess)" OnClick="@(_ => SaveScannerSettings())">Save Settings</MudButton>
</MudCardActions>
</MudCard>
<MudCard Class="mb-6" Style="width: 350px">
<MudCardHeader>
<CardHeaderContent>
<MudText Typo="Typo.h6">Playout Settings</MudText>
</CardHeaderContent>
</MudCardHeader>
<MudCardContent>
<MudForm @bind-IsValid="@_playoutSuccess">
<MudTextField T="int"
Label="Days To Build"
@bind-Value="_playoutSettings.DaysToBuild"
Validation="@(new Func<int, string>(ValidatePlayoutDaysToBuild))"
Required="true"
RequiredError="Days to build is required!"
Adornment="Adornment.End"
AdornmentText="Days"/>
<MudElement HtmlTag="div" Class="mt-3">
<MudTooltip Text="Controls whether file-not-found or unavailable items should be included in playouts">
<MudCheckBox Label="Skip Missing Items"
@bind-Checked="_playoutSettings.SkipMissingItems"
For="@(() => _playoutSettings.SkipMissingItems)"/>
</MudTooltip>
</MudElement>
</MudForm>
</MudCardContent>
<MudCardActions>
<MudButton Variant="Variant.Filled" Color="Color.Primary" Disabled="@(!_playoutSuccess)" OnClick="@(_ => SavePlayoutSettings())">Save Settings</MudButton>
</MudCardActions>
</MudCard>
</MudStack>
<MudStack Class="mr-6">
<MudCard Class="mb-6" Style="width: 350px">
<MudCardHeader>
<CardHeaderContent>
<MudText Typo="Typo.h6">Custom Resolutions</MudText>
</CardHeaderContent>
</MudCardHeader>
<MudCardContent>
@if (_customResolutions.Any())
{
<MudTable Hover="true" Items="_customResolutions" Dense="true" Class="mt-6">
<ColGroup>
<col/>
<col style="width: 60px;"/>
</ColGroup>
<HeaderContent>
</HeaderContent>
<RowTemplate>
<MudTd DataLabel="Resolution">@context.Name</MudTd>
<MudTd>
<div style="align-items: center; display: flex;">
<MudTooltip Text="Delete Custom Resolution">
<MudIconButton Icon="@Icons.Material.Filled.Delete"
OnClick="@(() => DeleteCustomResolution(context))">
</MudIconButton>
</MudTooltip>
</div>
</MudTd>
</RowTemplate>
</MudTable>
}
</MudCardContent>
<MudCardActions>
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="@(_ => AddCustomResolution())" Class="ml-2">
Add Custom Resolution
</MudButton>
</MudCardActions>
</MudCard>
</MudStack>
</MudGrid>
</MudContainer> </MudContainer>
@code { @code {
@ -371,11 +371,8 @@
Right: _ => Snackbar.Add("Successfully saved general settings", Severity.Success)); Right: _ => Snackbar.Add("Successfully saved general settings", Severity.Success));
} }
private async Task RefreshCustomResolutions() private async Task RefreshCustomResolutions() => _customResolutions = await Mediator.Send(new GetAllResolutions(), _cts.Token)
{ .Map(list => list.Filter(r => r.IsCustom).ToList());
_customResolutions = await Mediator.Send(new GetAllResolutions(), _cts.Token)
.Map(list => list.Filter(r => r.IsCustom).ToList());
}
private async Task AddCustomResolution() private async Task AddCustomResolution()
{ {
@ -415,4 +412,5 @@
await RefreshCustomResolutions(); await RefreshCustomResolutions();
} }
} }
} }

440
ErsatzTV/Pages/Trash.razor

@ -89,234 +89,234 @@
</div> </div>
</MudPaper> </MudPaper>
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Style="margin-top: 96px"> <MudContainer MaxWidth="MaxWidth.ExtraLarge" Style="margin-top: 96px">
@if (_movies?.Count > 0) @if (_movies?.Count > 0)
{ {
<div class="mb-4" style="align-items: baseline; display: flex; flex-direction: row;"> <div class="mb-4" style="align-items: baseline; display: flex; flex-direction: row;">
<MudText Typo="Typo.h4" <MudText Typo="Typo.h4"
Style="scroll-margin-top: 160px" Style="scroll-margin-top: 160px"
UserAttributes="@(new Dictionary<string, object> { { "id", "movies" } })"> UserAttributes="@(new Dictionary<string, object> { { "id", "movies" } })">
Movies Movies
</MudText> </MudText>
@if (_movies.Count > 50) @if (_movies.Count > 50)
{ {
<MudLink Href="@GetMoviesLink()" Class="ml-4">See All >></MudLink> <MudLink Href="@GetMoviesLink()" Class="ml-4">See All >></MudLink>
} }
</div> </div>
<MudContainer MaxWidth="MaxWidth.False" Class="media-card-grid"> <MudContainer MaxWidth="MaxWidth.False" Class="media-card-grid">
@foreach (MovieCardViewModel card in _movies.Cards.OrderBy(m => m.SortTitle)) @foreach (MovieCardViewModel card in _movies.Cards.OrderBy(m => m.SortTitle))
{ {
<MediaCard Data="@card" <MediaCard Data="@card"
Link="@($"media/movies/{card.MovieId}")" Link="@($"media/movies/{card.MovieId}")"
DeleteClicked="@DeleteItemFromDatabase" DeleteClicked="@DeleteItemFromDatabase"
SelectColor="@Color.Error" SelectColor="@Color.Error"
SelectClicked="@(e => SelectClicked(card, e))" SelectClicked="@(e => SelectClicked(card, e))"
IsSelected="@IsSelected(card)" IsSelected="@IsSelected(card)"
IsSelectMode="@IsSelectMode()"/> IsSelectMode="@IsSelectMode()"/>
} }
</MudContainer> </MudContainer>
} }
@if (_shows?.Count > 0)
{
<div class="mb-4" style="align-items: baseline; display: flex; flex-direction: row;">
<MudText Typo="Typo.h4"
Style="scroll-margin-top: 160px"
UserAttributes="@(new Dictionary<string, object> { { "id", "shows" } })">
Shows
</MudText>
@if (_shows.Count > 50)
{
<MudLink Href="@GetShowsLink()" Class="ml-4">See All >></MudLink>
}
</div>
<MudContainer MaxWidth="MaxWidth.False" Class="media-card-grid"> @if (_shows?.Count > 0)
@foreach (TelevisionShowCardViewModel card in _shows.Cards.OrderBy(s => s.SortTitle)) {
{ <div class="mb-4" style="align-items: baseline; display: flex; flex-direction: row;">
<MediaCard Data="@card" <MudText Typo="Typo.h4"
Link="@($"media/tv/shows/{card.TelevisionShowId}")" Style="scroll-margin-top: 160px"
DeleteClicked="@DeleteItemFromDatabase" UserAttributes="@(new Dictionary<string, object> { { "id", "shows" } })">
SelectColor="@Color.Error" Shows
SelectClicked="@(e => SelectClicked(card, e))" </MudText>
IsSelected="@IsSelected(card)" @if (_shows.Count > 50)
IsSelectMode="@IsSelectMode()"/> {
} <MudLink Href="@GetShowsLink()" Class="ml-4">See All >></MudLink>
</MudContainer> }
} </div>
@if (_seasons?.Count > 0)
{
<div class="mb-4" style="align-items: baseline; display: flex; flex-direction: row;">
<MudText Typo="Typo.h4"
Style="scroll-margin-top: 160px"
UserAttributes="@(new Dictionary<string, object> { { "id", "seasons" } })">
Seasons
</MudText>
@if (_seasons.Count > 50)
{
<MudLink Href="@GetSeasonsLink()" Class="ml-4">See All >></MudLink>
}
</div>
<MudContainer MaxWidth="MaxWidth.False" Class="media-card-grid"> <MudContainer MaxWidth="MaxWidth.False" Class="media-card-grid">
@foreach (TelevisionSeasonCardViewModel card in _seasons.Cards.OrderBy(s => s.SortTitle)) @foreach (TelevisionShowCardViewModel card in _shows.Cards.OrderBy(s => s.SortTitle))
{ {
<MediaCard Data="@card" <MediaCard Data="@card"
Link="@($"media/tv/seasons/{card.TelevisionSeasonId}")" Link="@($"media/tv/shows/{card.TelevisionShowId}")"
DeleteClicked="@DeleteItemFromDatabase" DeleteClicked="@DeleteItemFromDatabase"
SelectColor="@Color.Error" SelectColor="@Color.Error"
SelectClicked="@(e => SelectClicked(card, e))" SelectClicked="@(e => SelectClicked(card, e))"
IsSelected="@IsSelected(card)" IsSelected="@IsSelected(card)"
IsSelectMode="@IsSelectMode()"/> IsSelectMode="@IsSelectMode()"/>
} }
</MudContainer> </MudContainer>
} }
@if (_episodes?.Count > 0)
{
<div class="mb-4" style="align-items: baseline; display: flex; flex-direction: row;">
<MudText Typo="Typo.h4"
Style="scroll-margin-top: 160px"
UserAttributes="@(new Dictionary<string, object> { { "id", "episodes" } })">
Episodes
</MudText>
@if (_episodes.Count > 50)
{
<MudLink Href="@GetEpisodesLink()" Class="ml-4">See All >></MudLink>
}
</div>
<MudContainer MaxWidth="MaxWidth.False" Class="media-card-grid"> @if (_seasons?.Count > 0)
@foreach (TelevisionEpisodeCardViewModel card in _episodes.Cards.OrderBy(s => s.SortTitle)) {
{ <div class="mb-4" style="align-items: baseline; display: flex; flex-direction: row;">
<MediaCard Data="@card" <MudText Typo="Typo.h4"
DeleteClicked="@DeleteItemFromDatabase" Style="scroll-margin-top: 160px"
SelectColor="@Color.Error" UserAttributes="@(new Dictionary<string, object> { { "id", "seasons" } })">
Link="@($"media/tv/seasons/{card.SeasonId}#episode-{card.EpisodeId}")" Seasons
Subtitle="@($"{card.ShowTitle} - S{card.Season} E{card.Episode}")" </MudText>
SelectClicked="@(e => SelectClicked(card, e))" @if (_seasons.Count > 50)
IsSelected="@IsSelected(card)" {
IsSelectMode="@IsSelectMode()"/> <MudLink Href="@GetSeasonsLink()" Class="ml-4">See All >></MudLink>
} }
</MudContainer> </div>
}
@if (_artists?.Count > 0)
{
<div class="mb-4" style="align-items: baseline; display: flex; flex-direction: row;">
<MudText Typo="Typo.h4"
Style="scroll-margin-top: 160px"
UserAttributes="@(new Dictionary<string, object> { { "id", "artists" } })">
Artists
</MudText>
@if (_artists.Count > 50)
{
<MudLink Href="@GetArtistsLink()" Class="ml-4">See All >></MudLink>
}
</div>
<MudContainer MaxWidth="MaxWidth.False" Class="media-card-grid"> <MudContainer MaxWidth="MaxWidth.False" Class="media-card-grid">
@foreach (ArtistCardViewModel card in _artists.Cards.OrderBy(s => s.SortTitle)) @foreach (TelevisionSeasonCardViewModel card in _seasons.Cards.OrderBy(s => s.SortTitle))
{ {
<MediaCard Data="@card" <MediaCard Data="@card"
Link="@($"media/music/artists/{card.ArtistId}")" Link="@($"media/tv/seasons/{card.TelevisionSeasonId}")"
ArtworkKind="ArtworkKind.Thumbnail" DeleteClicked="@DeleteItemFromDatabase"
DeleteClicked="@DeleteItemFromDatabase" SelectColor="@Color.Error"
SelectColor="@Color.Error" SelectClicked="@(e => SelectClicked(card, e))"
SelectClicked="@(e => SelectClicked(card, e))" IsSelected="@IsSelected(card)"
IsSelected="@IsSelected(card)" IsSelectMode="@IsSelectMode()"/>
IsSelectMode="@IsSelectMode()"/> }
} </MudContainer>
</MudContainer> }
}
@if (_musicVideos?.Count > 0)
{
<div class="mb-4" style="align-items: baseline; display: flex; flex-direction: row;">
<MudText Typo="Typo.h4"
Style="scroll-margin-top: 160px"
UserAttributes="@(new Dictionary<string, object> { { "id", "music_videos" } })">
Music Videos
</MudText>
@if (_musicVideos.Count > 50)
{
<MudLink Href="@GetMusicVideosLink()" Class="ml-4">See All >></MudLink>
}
</div>
<MudContainer MaxWidth="MaxWidth.False" Class="media-card-grid"> @if (_episodes?.Count > 0)
@foreach (MusicVideoCardViewModel card in _musicVideos.Cards.OrderBy(s => s.SortTitle)) {
{ <div class="mb-4" style="align-items: baseline; display: flex; flex-direction: row;">
<MediaCard Data="@card" <MudText Typo="Typo.h4"
Link="" Style="scroll-margin-top: 160px"
ArtworkKind="ArtworkKind.Thumbnail" UserAttributes="@(new Dictionary<string, object> { { "id", "episodes" } })">
DeleteClicked="@DeleteItemFromDatabase" Episodes
SelectColor="@Color.Error" </MudText>
SelectClicked="@(e => SelectClicked(card, e))" @if (_episodes.Count > 50)
IsSelected="@IsSelected(card)" {
IsSelectMode="@IsSelectMode()"/> <MudLink Href="@GetEpisodesLink()" Class="ml-4">See All >></MudLink>
} }
</MudContainer> </div>
}
@if (_otherVideos?.Count > 0)
{
<div class="mb-4" style="align-items: baseline; display: flex; flex-direction: row;">
<MudText Typo="Typo.h4"
Style="scroll-margin-top: 160px"
UserAttributes="@(new Dictionary<string, object> { { "id", "other_videos" } })">
Other Videos
</MudText>
@if (_otherVideos.Count > 50)
{
<MudLink Href="@GetOtherVideosLink()" Class="ml-4">See All >></MudLink>
}
</div>
<MudContainer MaxWidth="MaxWidth.False" Class="media-card-grid"> <MudContainer MaxWidth="MaxWidth.False" Class="media-card-grid">
@foreach (OtherVideoCardViewModel card in _otherVideos.Cards.OrderBy(s => s.SortTitle)) @foreach (TelevisionEpisodeCardViewModel card in _episodes.Cards.OrderBy(s => s.SortTitle))
{ {
<MediaCard Data="@card" <MediaCard Data="@card"
Link="" DeleteClicked="@DeleteItemFromDatabase"
ArtworkKind="ArtworkKind.Thumbnail" SelectColor="@Color.Error"
DeleteClicked="@DeleteItemFromDatabase" Link="@($"media/tv/seasons/{card.SeasonId}#episode-{card.EpisodeId}")"
SelectColor="@Color.Error" Subtitle="@($"{card.ShowTitle} - S{card.Season} E{card.Episode}")"
SelectClicked="@(e => SelectClicked(card, e))" SelectClicked="@(e => SelectClicked(card, e))"
IsSelected="@IsSelected(card)" IsSelected="@IsSelected(card)"
IsSelectMode="@IsSelectMode()"/> IsSelectMode="@IsSelectMode()"/>
} }
</MudContainer> </MudContainer>
} }
@if (_songs?.Count > 0)
{
<div class="mb-4" style="align-items: baseline; display: flex; flex-direction: row;">
<MudText Typo="Typo.h4"
Style="scroll-margin-top: 160px"
UserAttributes="@(new Dictionary<string, object> { { "id", "songs" } })">
Songs
</MudText>
@if (_songs.Count > 50)
{
<MudLink Href="@GetSongsLink()" Class="ml-4">See All >></MudLink>
}
</div>
<MudContainer MaxWidth="MaxWidth.False" Class="media-card-grid"> @if (_artists?.Count > 0)
@foreach (SongCardViewModel card in _songs.Cards.OrderBy(s => s.SortTitle)) {
{ <div class="mb-4" style="align-items: baseline; display: flex; flex-direction: row;">
<MediaCard Data="@card" <MudText Typo="Typo.h4"
Link="" Style="scroll-margin-top: 160px"
ArtworkKind="ArtworkKind.Thumbnail" UserAttributes="@(new Dictionary<string, object> { { "id", "artists" } })">
DeleteClicked="@DeleteItemFromDatabase" Artists
SelectColor="@Color.Error" </MudText>
SelectClicked="@(e => SelectClicked(card, e))" @if (_artists.Count > 50)
IsSelected="@IsSelected(card)" {
IsSelectMode="@IsSelectMode()"/> <MudLink Href="@GetArtistsLink()" Class="ml-4">See All >></MudLink>
} }
</MudContainer> </div>
}
<MudContainer MaxWidth="MaxWidth.False" Class="media-card-grid">
@foreach (ArtistCardViewModel card in _artists.Cards.OrderBy(s => s.SortTitle))
{
<MediaCard Data="@card"
Link="@($"media/music/artists/{card.ArtistId}")"
ArtworkKind="ArtworkKind.Thumbnail"
DeleteClicked="@DeleteItemFromDatabase"
SelectColor="@Color.Error"
SelectClicked="@(e => SelectClicked(card, e))"
IsSelected="@IsSelected(card)"
IsSelectMode="@IsSelectMode()"/>
}
</MudContainer>
}
@if (_musicVideos?.Count > 0)
{
<div class="mb-4" style="align-items: baseline; display: flex; flex-direction: row;">
<MudText Typo="Typo.h4"
Style="scroll-margin-top: 160px"
UserAttributes="@(new Dictionary<string, object> { { "id", "music_videos" } })">
Music Videos
</MudText>
@if (_musicVideos.Count > 50)
{
<MudLink Href="@GetMusicVideosLink()" Class="ml-4">See All >></MudLink>
}
</div>
<MudContainer MaxWidth="MaxWidth.False" Class="media-card-grid">
@foreach (MusicVideoCardViewModel card in _musicVideos.Cards.OrderBy(s => s.SortTitle))
{
<MediaCard Data="@card"
Link=""
ArtworkKind="ArtworkKind.Thumbnail"
DeleteClicked="@DeleteItemFromDatabase"
SelectColor="@Color.Error"
SelectClicked="@(e => SelectClicked(card, e))"
IsSelected="@IsSelected(card)"
IsSelectMode="@IsSelectMode()"/>
}
</MudContainer>
}
@if (_otherVideos?.Count > 0)
{
<div class="mb-4" style="align-items: baseline; display: flex; flex-direction: row;">
<MudText Typo="Typo.h4"
Style="scroll-margin-top: 160px"
UserAttributes="@(new Dictionary<string, object> { { "id", "other_videos" } })">
Other Videos
</MudText>
@if (_otherVideos.Count > 50)
{
<MudLink Href="@GetOtherVideosLink()" Class="ml-4">See All >></MudLink>
}
</div>
<MudContainer MaxWidth="MaxWidth.False" Class="media-card-grid">
@foreach (OtherVideoCardViewModel card in _otherVideos.Cards.OrderBy(s => s.SortTitle))
{
<MediaCard Data="@card"
Link=""
ArtworkKind="ArtworkKind.Thumbnail"
DeleteClicked="@DeleteItemFromDatabase"
SelectColor="@Color.Error"
SelectClicked="@(e => SelectClicked(card, e))"
IsSelected="@IsSelected(card)"
IsSelectMode="@IsSelectMode()"/>
}
</MudContainer>
}
@if (_songs?.Count > 0)
{
<div class="mb-4" style="align-items: baseline; display: flex; flex-direction: row;">
<MudText Typo="Typo.h4"
Style="scroll-margin-top: 160px"
UserAttributes="@(new Dictionary<string, object> { { "id", "songs" } })">
Songs
</MudText>
@if (_songs.Count > 50)
{
<MudLink Href="@GetSongsLink()" Class="ml-4">See All >></MudLink>
}
</div>
<MudContainer MaxWidth="MaxWidth.False" Class="media-card-grid">
@foreach (SongCardViewModel card in _songs.Cards.OrderBy(s => s.SortTitle))
{
<MediaCard Data="@card"
Link=""
ArtworkKind="ArtworkKind.Thumbnail"
DeleteClicked="@DeleteItemFromDatabase"
SelectColor="@Color.Error"
SelectClicked="@(e => SelectClicked(card, e))"
IsSelected="@IsSelected(card)"
IsSelectMode="@IsSelectMode()"/>
}
</MudContainer>
}
</MudContainer> </MudContainer>
@code { @code {

5
ErsatzTV/Program.cs

@ -43,7 +43,10 @@ public class Program
public static async Task<int> Main(string[] args) public static async Task<int> Main(string[] args)
{ {
using var _ = new Mutex(true, "Global\\ErsatzTV.Singleton.74360cd8985c4d1fb6bc9e81887206fe", out bool createdNew); using var _ = new Mutex(
true,
"Global\\ErsatzTV.Singleton.74360cd8985c4d1fb6bc9e81887206fe",
out bool createdNew);
if (!createdNew) if (!createdNew)
{ {
Console.WriteLine("Another instance of ErsatztTV is already running."); Console.WriteLine("Another instance of ErsatztTV is already running.");

2
ErsatzTV/Services/EmbyService.cs

@ -30,7 +30,7 @@ public class EmbyService : BackgroundService
protected override async Task ExecuteAsync(CancellationToken cancellationToken) protected override async Task ExecuteAsync(CancellationToken cancellationToken)
{ {
await Task.Yield(); await Task.Yield();
await _systemStartup.WaitForDatabase(cancellationToken); await _systemStartup.WaitForDatabase(cancellationToken);
if (cancellationToken.IsCancellationRequested) if (cancellationToken.IsCancellationRequested)
{ {

2
ErsatzTV/Services/FFmpegWorkerService.cs

@ -28,7 +28,7 @@ public class FFmpegWorkerService : BackgroundService
protected override async Task ExecuteAsync(CancellationToken cancellationToken) protected override async Task ExecuteAsync(CancellationToken cancellationToken)
{ {
await Task.Yield(); await Task.Yield();
try try
{ {
_logger.LogInformation("FFmpeg worker service started"); _logger.LogInformation("FFmpeg worker service started");

2
ErsatzTV/Services/RunOnce/CacheCleanerService.cs

@ -31,7 +31,7 @@ public class CacheCleanerService : BackgroundService
{ {
return; return;
} }
using IServiceScope scope = _serviceScopeFactory.CreateScope(); using IServiceScope scope = _serviceScopeFactory.CreateScope();
await using TvContext dbContext = scope.ServiceProvider.GetRequiredService<TvContext>(); await using TvContext dbContext = scope.ServiceProvider.GetRequiredService<TvContext>();
ILocalFileSystem localFileSystem = scope.ServiceProvider.GetRequiredService<ILocalFileSystem>(); ILocalFileSystem localFileSystem = scope.ServiceProvider.GetRequiredService<ILocalFileSystem>();

2
ErsatzTV/Services/RunOnce/DatabaseMigratorService.cs

@ -23,7 +23,7 @@ public class DatabaseMigratorService : BackgroundService
protected override async Task ExecuteAsync(CancellationToken cancellationToken) protected override async Task ExecuteAsync(CancellationToken cancellationToken)
{ {
await Task.Yield(); await Task.Yield();
_logger.LogInformation("Applying database migrations"); _logger.LogInformation("Applying database migrations");
using IServiceScope scope = _serviceScopeFactory.CreateScope(); using IServiceScope scope = _serviceScopeFactory.CreateScope();

2
ErsatzTV/Services/ScannerService.cs

@ -31,7 +31,7 @@ public class ScannerService : BackgroundService
protected override async Task ExecuteAsync(CancellationToken cancellationToken) protected override async Task ExecuteAsync(CancellationToken cancellationToken)
{ {
await Task.Yield(); await Task.Yield();
try try
{ {
_logger.LogInformation("Scanner service started"); _logger.LogInformation("Scanner service started");

4
ErsatzTV/Services/SchedulerService.cs

@ -21,10 +21,10 @@ namespace ErsatzTV.Services;
public class SchedulerService : BackgroundService public class SchedulerService : BackgroundService
{ {
private readonly IEntityLocker _entityLocker; private readonly IEntityLocker _entityLocker;
private readonly SystemStartup _systemStartup;
private readonly ILogger<SchedulerService> _logger; private readonly ILogger<SchedulerService> _logger;
private readonly ChannelWriter<IScannerBackgroundServiceRequest> _scannerWorkerChannel; private readonly ChannelWriter<IScannerBackgroundServiceRequest> _scannerWorkerChannel;
private readonly IServiceScopeFactory _serviceScopeFactory; private readonly IServiceScopeFactory _serviceScopeFactory;
private readonly SystemStartup _systemStartup;
private readonly ChannelWriter<IBackgroundServiceRequest> _workerChannel; private readonly ChannelWriter<IBackgroundServiceRequest> _workerChannel;
public SchedulerService( public SchedulerService(
@ -46,7 +46,7 @@ public class SchedulerService : BackgroundService
protected override async Task ExecuteAsync(CancellationToken cancellationToken) protected override async Task ExecuteAsync(CancellationToken cancellationToken)
{ {
await Task.Yield(); await Task.Yield();
await _systemStartup.WaitForSearchIndex(cancellationToken); await _systemStartup.WaitForSearchIndex(cancellationToken);
if (cancellationToken.IsCancellationRequested) if (cancellationToken.IsCancellationRequested)
{ {

2
ErsatzTV/Services/SearchIndexService.cs

@ -25,7 +25,7 @@ public class SearchIndexService : BackgroundService
protected override async Task ExecuteAsync(CancellationToken cancellationToken) protected override async Task ExecuteAsync(CancellationToken cancellationToken)
{ {
await Task.Yield(); await Task.Yield();
try try
{ {
_logger.LogInformation("Search index worker service started"); _logger.LogInformation("Search index worker service started");

2
ErsatzTV/Services/WorkerService.cs

@ -30,7 +30,7 @@ public class WorkerService : BackgroundService
protected override async Task ExecuteAsync(CancellationToken cancellationToken) protected override async Task ExecuteAsync(CancellationToken cancellationToken)
{ {
await Task.Yield(); await Task.Yield();
try try
{ {
_logger.LogInformation("Worker service started"); _logger.LogInformation("Worker service started");

15
ErsatzTV/Shared/AddCustomResolutionDialog.razor

@ -25,19 +25,16 @@
private readonly ResolutionEditViewModel _model = new(); private readonly ResolutionEditViewModel _model = new();
private void Submit() private void Submit() =>
{ // if (!CanSubmit())
// if (!CanSubmit()) // {
// { // return;
// return; // }
// }
MudDialog.Close(DialogResult.Ok(_model)); MudDialog.Close(DialogResult.Ok(_model));
}
private void Cancel(MouseEventArgs e) private void Cancel(MouseEventArgs e)
{ {
// this is gross, but [enter] seems to sometimes trigger cancel instead of submit // this is gross, but [enter] seems to sometimes trigger cancel instead of submit
if (e.Detail == 0) if (e.Detail == 0)
{ {
Submit(); Submit();

5
ErsatzTV/Shared/MainLayout.razor

@ -204,10 +204,7 @@
} }
} }
private async void OnStartupProgress(object sender, EventArgs e) private async void OnStartupProgress(object sender, EventArgs e) => await InvokeAsync(StateHasChanged);
{
await InvokeAsync(StateHasChanged);
}
protected override async Task OnParametersSetAsync() protected override async Task OnParametersSetAsync()
{ {

5
ErsatzTV/ViewModels/ProgramScheduleItemEditViewModel.cs

@ -12,8 +12,8 @@ namespace ErsatzTV.ViewModels;
public class ProgramScheduleItemEditViewModel : INotifyPropertyChanged public class ProgramScheduleItemEditViewModel : INotifyPropertyChanged
{ {
private ProgramScheduleItemCollectionType _collectionType; private ProgramScheduleItemCollectionType _collectionType;
private int? _multipleCount;
private int? _discardToFillAttempts; private int? _discardToFillAttempts;
private int? _multipleCount;
private TimeSpan? _playoutDuration; private TimeSpan? _playoutDuration;
private TimeSpan? _startTime; private TimeSpan? _startTime;
@ -97,12 +97,13 @@ public class ProgramScheduleItemEditViewModel : INotifyPropertyChanged
} }
public TailMode TailMode { get; set; } public TailMode TailMode { get; set; }
public int? DiscardToFillAttempts public int? DiscardToFillAttempts
{ {
get => PlayoutMode == PlayoutMode.Duration ? _discardToFillAttempts ?? 0 : null; get => PlayoutMode == PlayoutMode.Duration ? _discardToFillAttempts ?? 0 : null;
set => _discardToFillAttempts = value; set => _discardToFillAttempts = value;
} }
public string CustomTitle { get; set; } public string CustomTitle { get; set; }
public GuideMode GuideMode { get; set; } public GuideMode GuideMode { get; set; }

Loading…
Cancel
Save