Browse Source

work around sequential schedule validation limit (#2655)

* remove readalltext

* remove unused method

* remove fileexists

* remove folderexists

* remove readalllines

* remove fake local file system

* show playlist name in playout build errors

* add basic sequential schedule validator tests

* work around sequential schedule validation limit
pull/2656/head
Jason Dove 1 month ago committed by GitHub
parent
commit
ec0d8ea6ac
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 3
      CHANGELOG.md
  2. 10
      ErsatzTV.Application/Channels/Commands/DeleteChannelHandler.cs
  3. 16
      ErsatzTV.Application/Channels/Commands/RefreshChannelDataHandler.cs
  4. 8
      ErsatzTV.Application/Channels/Commands/RefreshChannelListHandler.cs
  5. 6
      ErsatzTV.Application/Channels/Queries/GetChannelGuideHandler.cs
  6. 6
      ErsatzTV.Application/FFmpegProfiles/Commands/UpdateFFmpegSettingsHandler.cs
  7. 4
      ErsatzTV.Application/Graphics/Commands/RefreshGraphicsElementsHandler.cs
  8. 10
      ErsatzTV.Application/Playouts/Commands/CreateExternalJsonPlayoutHandler.cs
  9. 30
      ErsatzTV.Application/Playouts/Commands/CreateScriptedPlayoutHandler.cs
  10. 31
      ErsatzTV.Application/Playouts/Commands/CreateSequentialPlayoutHandler.cs
  11. 36
      ErsatzTV.Application/Playouts/Commands/DeletePlayoutHandler.cs
  12. 6
      ErsatzTV.Application/Playouts/Commands/UpdateScriptedPlayoutHandler.cs
  13. 7
      ErsatzTV.Application/Streaming/Commands/StartFFmpegSessionHandler.cs
  14. 26
      ErsatzTV.Application/Streaming/HlsSessionWorker.cs
  15. 8
      ErsatzTV.Application/Streaming/Queries/GetLastPtsTimeHandler.cs
  16. 8
      ErsatzTV.Application/Streaming/Queries/GetPlayoutItemProcessByChannelNumberHandler.cs
  17. 6
      ErsatzTV.Application/Subtitles/Commands/ExtractEmbeddedShowSubtitlesHandler.cs
  18. 8
      ErsatzTV.Application/Subtitles/Commands/ExtractEmbeddedSubtitlesHandler.cs
  19. 12
      ErsatzTV.Application/Subtitles/Commands/ExtractEmbeddedSubtitlesHandlerBase.cs
  20. 4
      ErsatzTV.Application/Troubleshooting/Commands/PrepareTroubleshootingPlaybackHandler.cs
  21. 1
      ErsatzTV.Core.Tests/ErsatzTV.Core.Tests.csproj
  22. 177
      ErsatzTV.Core.Tests/FFmpeg/CustomStreamSelectorTests.cs
  23. 7
      ErsatzTV.Core.Tests/FFmpeg/FFmpegStreamSelectorTests.cs
  24. 8
      ErsatzTV.Core.Tests/Fakes/FakeFileEntry.cs
  25. 3
      ErsatzTV.Core.Tests/Fakes/FakeFolderEntry.cs
  26. 99
      ErsatzTV.Core.Tests/Fakes/FakeLocalFileSystem.cs
  27. 11
      ErsatzTV.Core.Tests/Scheduling/ClassicScheduling/ContinuePlayoutTests.cs
  28. 35
      ErsatzTV.Core.Tests/Scheduling/ClassicScheduling/NewPlayoutTests.cs
  29. 8
      ErsatzTV.Core.Tests/Scheduling/ClassicScheduling/PlayoutBuilderTestBase.cs
  30. 5
      ErsatzTV.Core.Tests/Scheduling/ClassicScheduling/RefreshPlayoutTests.cs
  31. 6
      ErsatzTV.Core.Tests/Scheduling/ScheduleIntegrationTests.cs
  32. 1
      ErsatzTV.Core/ErsatzTV.Core.csproj
  33. 8
      ErsatzTV.Core/FFmpeg/CustomStreamSelector.cs
  34. 11
      ErsatzTV.Core/FFmpeg/FFmpegStreamSelector.cs
  35. 9
      ErsatzTV.Core/Interfaces/Metadata/ILocalFileSystem.cs
  36. 59
      ErsatzTV.Core/Metadata/LocalFileSystem.cs
  37. 16
      ErsatzTV.Core/Scheduling/PlayoutBuilder.cs
  38. 6
      ErsatzTV.Core/Scheduling/ScriptedScheduling/ScriptedPlayoutBuilder.cs
  39. 6
      ErsatzTV.Core/Scheduling/YamlScheduling/SequentialPlayoutBuilder.cs
  40. 7
      ErsatzTV.Infrastructure.Tests/ErsatzTV.Infrastructure.Tests.csproj
  41. 2
      ErsatzTV.Infrastructure.Tests/Metadata/LocalStatisticsProviderTests.cs
  42. 200
      ErsatzTV.Infrastructure.Tests/Scheduling/SequentialScheduleValidatorTests.cs
  43. 46
      ErsatzTV.Infrastructure/Data/Repositories/LibraryRepository.cs
  44. 5
      ErsatzTV.Infrastructure/Data/Repositories/MediaCollectionRepository.cs
  45. 8
      ErsatzTV.Infrastructure/Data/Repositories/TemplateDataRepository.cs
  46. 1
      ErsatzTV.Infrastructure/ErsatzTV.Infrastructure.csproj
  47. 8
      ErsatzTV.Infrastructure/FFmpeg/MpegTsScriptService.cs
  48. 30
      ErsatzTV.Infrastructure/Images/ImageCache.cs
  49. 6
      ErsatzTV.Infrastructure/Metadata/LocalStatisticsProvider.cs
  50. 62
      ErsatzTV.Infrastructure/Scheduling/SequentialScheduleValidator.cs
  51. 13
      ErsatzTV.Infrastructure/Streaming/ExternalJsonPlayoutItemProvider.cs
  52. 10
      ErsatzTV.Infrastructure/Streaming/Graphics/GraphicsElementLoader.cs
  53. 18
      ErsatzTV.Scanner.Tests/Core/FFmpeg/TranscodingTests.cs
  54. 88
      ErsatzTV.Scanner.Tests/Core/Fakes/FakeLocalFileSystem.cs
  55. 22
      ErsatzTV.Scanner.Tests/Core/Metadata/LocalSubtitlesProviderTests.cs
  56. 38
      ErsatzTV.Scanner.Tests/Core/Metadata/MovieFolderScannerTests.cs
  57. 1
      ErsatzTV.Scanner.Tests/ErsatzTV.Scanner.Tests.csproj
  58. 8
      ErsatzTV.Scanner/Core/Emby/EmbyMovieLibraryScanner.cs
  59. 8
      ErsatzTV.Scanner/Core/Emby/EmbyTelevisionLibraryScanner.cs
  60. 8
      ErsatzTV.Scanner/Core/Jellyfin/JellyfinMovieLibraryScanner.cs
  61. 8
      ErsatzTV.Scanner/Core/Jellyfin/JellyfinTelevisionLibraryScanner.cs
  62. 8
      ErsatzTV.Scanner/Core/Metadata/ImageFolderScanner.cs
  63. 13
      ErsatzTV.Scanner/Core/Metadata/LocalFolderScanner.cs
  64. 10
      ErsatzTV.Scanner/Core/Metadata/LocalMetadataProvider.cs
  65. 2
      ErsatzTV.Scanner/Core/Metadata/LocalSubtitlesProvider.cs
  66. 14
      ErsatzTV.Scanner/Core/Metadata/MediaServerMovieLibraryScanner.cs
  67. 14
      ErsatzTV.Scanner/Core/Metadata/MediaServerOtherVideoLibraryScanner.cs
  68. 18
      ErsatzTV.Scanner/Core/Metadata/MediaServerTelevisionLibraryScanner.cs
  69. 14
      ErsatzTV.Scanner/Core/Metadata/MovieFolderScanner.cs
  70. 16
      ErsatzTV.Scanner/Core/Metadata/MusicVideoFolderScanner.cs
  71. 12
      ErsatzTV.Scanner/Core/Metadata/OtherVideoFolderScanner.cs
  72. 8
      ErsatzTV.Scanner/Core/Metadata/RemoteStreamFolderScanner.cs
  73. 10
      ErsatzTV.Scanner/Core/Metadata/SongFolderScanner.cs
  74. 18
      ErsatzTV.Scanner/Core/Metadata/TelevisionFolderScanner.cs
  75. 8
      ErsatzTV.Scanner/Core/Plex/PlexMovieLibraryScanner.cs
  76. 6
      ErsatzTV.Scanner/Core/Plex/PlexOtherVideoLibraryScanner.cs
  77. 8
      ErsatzTV.Scanner/Core/Plex/PlexTelevisionLibraryScanner.cs
  78. 4
      ErsatzTV.Scanner/Program.cs
  79. 6
      ErsatzTV/Controllers/Api/TroubleshootController.cs
  80. 6
      ErsatzTV/Pages/Troubleshooting/PlaybackTroubleshooting.razor
  81. 46
      ErsatzTV/Services/RunOnce/CacheCleanerService.cs
  82. 4
      ErsatzTV/Startup.cs

3
CHANGELOG.md

@ -84,6 +84,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- Add disabled text color and `(D)` and `(H)` labels for disabled and hidden channels in channel list - Add disabled text color and `(D)` and `(H)` labels for disabled and hidden channels in channel list
- Graphics engine: fix subtitle path escaping and font loading - Graphics engine: fix subtitle path escaping and font loading
- Fix corrupt output (green artifacts) when decoding certain 10-bit content using AMD Polaris GPUs - Fix corrupt output (green artifacts) when decoding certain 10-bit content using AMD Polaris GPUs
- Work around sequential schedule validation limit (1000/hr by Newtonsoft.Json.Schema library)
- Playout builds now use JsonSchema.Net library which has no validation limit
- Validation tool in the UI still uses Newtonsoft.Json.Schema (with 1000/hr limit) as the error output is easier to understand
### Changed ### Changed
- Classic schedules: `Refresh` classic playouts from playout list; do not `Reset` them - Classic schedules: `Refresh` classic playouts from playout list; do not `Reset` them

10
ErsatzTV.Application/Channels/Commands/DeleteChannelHandler.cs

@ -1,6 +1,6 @@
using System.IO.Abstractions;
using System.Threading.Channels; using System.Threading.Channels;
using ErsatzTV.Core; using ErsatzTV.Core;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Search; using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Infrastructure.Data; using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions; using ErsatzTV.Infrastructure.Extensions;
@ -12,19 +12,19 @@ namespace ErsatzTV.Application.Channels;
public class DeleteChannelHandler : IRequestHandler<DeleteChannel, Either<BaseError, Unit>> public class DeleteChannelHandler : IRequestHandler<DeleteChannel, Either<BaseError, Unit>>
{ {
private readonly IDbContextFactory<TvContext> _dbContextFactory; private readonly IDbContextFactory<TvContext> _dbContextFactory;
private readonly ILocalFileSystem _localFileSystem; private readonly IFileSystem _fileSystem;
private readonly ISearchTargets _searchTargets; private readonly ISearchTargets _searchTargets;
private readonly ChannelWriter<IBackgroundServiceRequest> _workerChannel; private readonly ChannelWriter<IBackgroundServiceRequest> _workerChannel;
public DeleteChannelHandler( public DeleteChannelHandler(
ChannelWriter<IBackgroundServiceRequest> workerChannel, ChannelWriter<IBackgroundServiceRequest> workerChannel,
IDbContextFactory<TvContext> dbContextFactory, IDbContextFactory<TvContext> dbContextFactory,
ILocalFileSystem localFileSystem, IFileSystem fileSystem,
ISearchTargets searchTargets) ISearchTargets searchTargets)
{ {
_workerChannel = workerChannel; _workerChannel = workerChannel;
_dbContextFactory = dbContextFactory; _dbContextFactory = dbContextFactory;
_localFileSystem = localFileSystem; _fileSystem = fileSystem;
_searchTargets = searchTargets; _searchTargets = searchTargets;
} }
@ -44,7 +44,7 @@ public class DeleteChannelHandler : IRequestHandler<DeleteChannel, Either<BaseEr
// delete channel data from channel guide cache // delete channel data from channel guide cache
string cacheFile = Path.Combine(FileSystemLayout.ChannelGuideCacheFolder, $"{channel.Number}.xml"); string cacheFile = Path.Combine(FileSystemLayout.ChannelGuideCacheFolder, $"{channel.Number}.xml");
if (_localFileSystem.FileExists(cacheFile)) if (_fileSystem.File.Exists(cacheFile))
{ {
File.Delete(cacheFile); File.Delete(cacheFile);
} }

16
ErsatzTV.Application/Channels/Commands/RefreshChannelDataHandler.cs

@ -1,4 +1,5 @@
using System.Globalization; using System.Globalization;
using System.IO.Abstractions;
using System.Xml; using System.Xml;
using ErsatzTV.Application.Configuration; using ErsatzTV.Application.Configuration;
using ErsatzTV.Core; using ErsatzTV.Core;
@ -26,6 +27,7 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
{ {
private readonly IConfigElementRepository _configElementRepository; private readonly IConfigElementRepository _configElementRepository;
private readonly IDbContextFactory<TvContext> _dbContextFactory; private readonly IDbContextFactory<TvContext> _dbContextFactory;
private readonly IFileSystem _fileSystem;
private readonly ILocalFileSystem _localFileSystem; private readonly ILocalFileSystem _localFileSystem;
private readonly ILogger<RefreshChannelDataHandler> _logger; private readonly ILogger<RefreshChannelDataHandler> _logger;
private readonly RecyclableMemoryStreamManager _recyclableMemoryStreamManager; private readonly RecyclableMemoryStreamManager _recyclableMemoryStreamManager;
@ -33,12 +35,14 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
public RefreshChannelDataHandler( public RefreshChannelDataHandler(
RecyclableMemoryStreamManager recyclableMemoryStreamManager, RecyclableMemoryStreamManager recyclableMemoryStreamManager,
IDbContextFactory<TvContext> dbContextFactory, IDbContextFactory<TvContext> dbContextFactory,
IFileSystem fileSystem,
ILocalFileSystem localFileSystem, ILocalFileSystem localFileSystem,
IConfigElementRepository configElementRepository, IConfigElementRepository configElementRepository,
ILogger<RefreshChannelDataHandler> logger) ILogger<RefreshChannelDataHandler> logger)
{ {
_recyclableMemoryStreamManager = recyclableMemoryStreamManager; _recyclableMemoryStreamManager = recyclableMemoryStreamManager;
_dbContextFactory = dbContextFactory; _dbContextFactory = dbContextFactory;
_fileSystem = fileSystem;
_localFileSystem = localFileSystem; _localFileSystem = localFileSystem;
_configElementRepository = configElementRepository; _configElementRepository = configElementRepository;
_logger = logger; _logger = logger;
@ -886,7 +890,7 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
"movie.sbntxt"); "movie.sbntxt");
// fail if file doesn't exist // fail if file doesn't exist
if (!_localFileSystem.FileExists(templateFileName)) if (!_fileSystem.File.Exists(templateFileName))
{ {
_logger.LogError( _logger.LogError(
"Unable to generate movie XMLTV fragment without template file {File}; please restart ErsatzTV", "Unable to generate movie XMLTV fragment without template file {File}; please restart ErsatzTV",
@ -905,7 +909,7 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
"episode.sbntxt"); "episode.sbntxt");
// fail if file doesn't exist // fail if file doesn't exist
if (!_localFileSystem.FileExists(templateFileName)) if (!_fileSystem.File.Exists(templateFileName))
{ {
_logger.LogError( _logger.LogError(
"Unable to generate episode XMLTV fragment without template file {File}; please restart ErsatzTV", "Unable to generate episode XMLTV fragment without template file {File}; please restart ErsatzTV",
@ -924,7 +928,7 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
"musicVideo.sbntxt"); "musicVideo.sbntxt");
// fail if file doesn't exist // fail if file doesn't exist
if (!_localFileSystem.FileExists(templateFileName)) if (!_fileSystem.File.Exists(templateFileName))
{ {
_logger.LogError( _logger.LogError(
"Unable to generate music video XMLTV fragment without template file {File}; please restart ErsatzTV", "Unable to generate music video XMLTV fragment without template file {File}; please restart ErsatzTV",
@ -943,7 +947,7 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
"song.sbntxt"); "song.sbntxt");
// fail if file doesn't exist // fail if file doesn't exist
if (!_localFileSystem.FileExists(templateFileName)) if (!_fileSystem.File.Exists(templateFileName))
{ {
_logger.LogError( _logger.LogError(
"Unable to generate song XMLTV fragment without template file {File}; please restart ErsatzTV", "Unable to generate song XMLTV fragment without template file {File}; please restart ErsatzTV",
@ -962,7 +966,7 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
"otherVideo.sbntxt"); "otherVideo.sbntxt");
// fail if file doesn't exist // fail if file doesn't exist
if (!_localFileSystem.FileExists(templateFileName)) if (!_fileSystem.File.Exists(templateFileName))
{ {
_logger.LogError( _logger.LogError(
"Unable to generate other video XMLTV fragment without template file {File}; please restart ErsatzTV", "Unable to generate other video XMLTV fragment without template file {File}; please restart ErsatzTV",
@ -1077,7 +1081,7 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
{ {
var result = new List<PlayoutItem>(); var result = new List<PlayoutItem>();
if (_localFileSystem.FileExists(path)) if (_fileSystem.File.Exists(path))
{ {
Option<ExternalJsonChannel> maybeChannel = JsonConvert.DeserializeObject<ExternalJsonChannel>( Option<ExternalJsonChannel> maybeChannel = JsonConvert.DeserializeObject<ExternalJsonChannel>(
await File.ReadAllTextAsync(path)); await File.ReadAllTextAsync(path));

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

@ -1,4 +1,5 @@
using System.Data.Common; using System.Data.Common;
using System.IO.Abstractions;
using System.Net; using System.Net;
using System.Xml; using System.Xml;
using Dapper; using Dapper;
@ -19,6 +20,7 @@ namespace ErsatzTV.Application.Channels;
public class RefreshChannelListHandler : IRequestHandler<RefreshChannelList> public class RefreshChannelListHandler : IRequestHandler<RefreshChannelList>
{ {
private readonly IDbContextFactory<TvContext> _dbContextFactory; private readonly IDbContextFactory<TvContext> _dbContextFactory;
private readonly IFileSystem _fileSystem;
private readonly ILocalFileSystem _localFileSystem; private readonly ILocalFileSystem _localFileSystem;
private readonly ILogger<RefreshChannelListHandler> _logger; private readonly ILogger<RefreshChannelListHandler> _logger;
private readonly RecyclableMemoryStreamManager _recyclableMemoryStreamManager; private readonly RecyclableMemoryStreamManager _recyclableMemoryStreamManager;
@ -26,11 +28,13 @@ public class RefreshChannelListHandler : IRequestHandler<RefreshChannelList>
public RefreshChannelListHandler( public RefreshChannelListHandler(
RecyclableMemoryStreamManager recyclableMemoryStreamManager, RecyclableMemoryStreamManager recyclableMemoryStreamManager,
IDbContextFactory<TvContext> dbContextFactory, IDbContextFactory<TvContext> dbContextFactory,
IFileSystem fileSystem,
ILocalFileSystem localFileSystem, ILocalFileSystem localFileSystem,
ILogger<RefreshChannelListHandler> logger) ILogger<RefreshChannelListHandler> logger)
{ {
_recyclableMemoryStreamManager = recyclableMemoryStreamManager; _recyclableMemoryStreamManager = recyclableMemoryStreamManager;
_dbContextFactory = dbContextFactory; _dbContextFactory = dbContextFactory;
_fileSystem = fileSystem;
_localFileSystem = localFileSystem; _localFileSystem = localFileSystem;
_logger = logger; _logger = logger;
} }
@ -44,13 +48,13 @@ public class RefreshChannelListHandler : IRequestHandler<RefreshChannelList>
string templateFileName = Path.Combine(FileSystemLayout.ChannelGuideTemplatesFolder, "channel.sbntxt"); string templateFileName = Path.Combine(FileSystemLayout.ChannelGuideTemplatesFolder, "channel.sbntxt");
// fall back to default template // fall back to default template
if (!_localFileSystem.FileExists(templateFileName)) if (!_fileSystem.File.Exists(templateFileName))
{ {
templateFileName = Path.Combine(FileSystemLayout.ChannelGuideTemplatesFolder, "_channel.sbntxt"); templateFileName = Path.Combine(FileSystemLayout.ChannelGuideTemplatesFolder, "_channel.sbntxt");
} }
// fail if file doesn't exist // fail if file doesn't exist
if (!_localFileSystem.FileExists(templateFileName)) if (!_fileSystem.File.Exists(templateFileName))
{ {
_logger.LogError( _logger.LogError(
"Unable to generate channel list without template file {File}; please restart ErsatzTV", "Unable to generate channel list without template file {File}; please restart ErsatzTV",

6
ErsatzTV.Application/Channels/Queries/GetChannelGuideHandler.cs

@ -1,4 +1,5 @@
using System.Collections.Immutable; using System.Collections.Immutable;
using System.IO.Abstractions;
using System.Text; using System.Text;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using ErsatzTV.Core; using ErsatzTV.Core;
@ -15,14 +16,17 @@ public partial class GetChannelGuideHandler : IRequestHandler<GetChannelGuide, E
private readonly IDbContextFactory<TvContext> _dbContextFactory; private readonly IDbContextFactory<TvContext> _dbContextFactory;
private readonly ILocalFileSystem _localFileSystem; private readonly ILocalFileSystem _localFileSystem;
private readonly RecyclableMemoryStreamManager _recyclableMemoryStreamManager; private readonly RecyclableMemoryStreamManager _recyclableMemoryStreamManager;
private readonly IFileSystem _fileSystem;
public GetChannelGuideHandler( public GetChannelGuideHandler(
IDbContextFactory<TvContext> dbContextFactory, IDbContextFactory<TvContext> dbContextFactory,
RecyclableMemoryStreamManager recyclableMemoryStreamManager, RecyclableMemoryStreamManager recyclableMemoryStreamManager,
IFileSystem fileSystem,
ILocalFileSystem localFileSystem) ILocalFileSystem localFileSystem)
{ {
_dbContextFactory = dbContextFactory; _dbContextFactory = dbContextFactory;
_recyclableMemoryStreamManager = recyclableMemoryStreamManager; _recyclableMemoryStreamManager = recyclableMemoryStreamManager;
_fileSystem = fileSystem;
_localFileSystem = localFileSystem; _localFileSystem = localFileSystem;
} }
@ -39,7 +43,7 @@ public partial class GetChannelGuideHandler : IRequestHandler<GetChannelGuide, E
.ToImmutableHashSet(); .ToImmutableHashSet();
string channelsFile = Path.Combine(FileSystemLayout.ChannelGuideCacheFolder, "channels.xml"); string channelsFile = Path.Combine(FileSystemLayout.ChannelGuideCacheFolder, "channels.xml");
if (!_localFileSystem.FileExists(channelsFile)) if (!_fileSystem.File.Exists(channelsFile))
{ {
return BaseError.New($"Required file {channelsFile} is missing"); return BaseError.New($"Required file {channelsFile} is missing");
} }

6
ErsatzTV.Application/FFmpegProfiles/Commands/UpdateFFmpegSettingsHandler.cs

@ -1,18 +1,18 @@
using System.Diagnostics; using System.Diagnostics;
using System.Globalization; using System.Globalization;
using System.IO.Abstractions;
using System.Threading.Channels; using System.Threading.Channels;
using ErsatzTV.Application.FFmpeg; using ErsatzTV.Application.FFmpeg;
using ErsatzTV.Application.Subtitles; using ErsatzTV.Application.Subtitles;
using ErsatzTV.Core; using ErsatzTV.Core;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Interfaces.Repositories;
namespace ErsatzTV.Application.FFmpegProfiles; namespace ErsatzTV.Application.FFmpegProfiles;
public class UpdateFFmpegSettingsHandler( public class UpdateFFmpegSettingsHandler(
IConfigElementRepository configElementRepository, IConfigElementRepository configElementRepository,
ILocalFileSystem localFileSystem, IFileSystem fileSystem,
ChannelWriter<IBackgroundServiceRequest> workerChannel) ChannelWriter<IBackgroundServiceRequest> workerChannel)
: IRequestHandler<UpdateFFmpegSettings, Either<BaseError, Unit>> : IRequestHandler<UpdateFFmpegSettings, Either<BaseError, Unit>>
{ {
@ -35,7 +35,7 @@ public class UpdateFFmpegSettingsHandler(
private async Task<Validation<BaseError, Unit>> ValidateToolPath(string path, string name) private async Task<Validation<BaseError, Unit>> ValidateToolPath(string path, string name)
{ {
if (!localFileSystem.FileExists(path)) if (!fileSystem.File.Exists(path))
{ {
return BaseError.New($"{name} path does not exist"); return BaseError.New($"{name} path does not exist");
} }

4
ErsatzTV.Application/Graphics/Commands/RefreshGraphicsElementsHandler.cs

@ -1,3 +1,4 @@
using System.IO.Abstractions;
using ErsatzTV.Core; using ErsatzTV.Core;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Metadata; using ErsatzTV.Core.Interfaces.Metadata;
@ -10,6 +11,7 @@ namespace ErsatzTV.Application.Graphics;
public class RefreshGraphicsElementsHandler( public class RefreshGraphicsElementsHandler(
IDbContextFactory<TvContext> dbContextFactory, IDbContextFactory<TvContext> dbContextFactory,
IFileSystem fileSystem,
ILocalFileSystem localFileSystem, ILocalFileSystem localFileSystem,
IGraphicsElementLoader graphicsElementLoader, IGraphicsElementLoader graphicsElementLoader,
ILogger<RefreshGraphicsElementsHandler> logger) ILogger<RefreshGraphicsElementsHandler> logger)
@ -24,7 +26,7 @@ public class RefreshGraphicsElementsHandler(
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
var missing = allExisting var missing = allExisting
.Where(e => !localFileSystem.FileExists(e.Path) || (Path.GetExtension(e.Path) != ".yml" && Path.GetExtension(e.Path) != ".yaml")) .Where(e => !fileSystem.File.Exists(e.Path) || (Path.GetExtension(e.Path) != ".yml" && Path.GetExtension(e.Path) != ".yaml"))
.ToList(); .ToList();
foreach (GraphicsElement existing in missing) foreach (GraphicsElement existing in missing)

10
ErsatzTV.Application/Playouts/Commands/CreateExternalJsonPlayoutHandler.cs

@ -1,8 +1,8 @@
using System.IO.Abstractions;
using System.Threading.Channels; using System.Threading.Channels;
using ErsatzTV.Application.Channels; using ErsatzTV.Application.Channels;
using ErsatzTV.Core; using ErsatzTV.Core;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Scheduling; using ErsatzTV.Core.Scheduling;
using ErsatzTV.Infrastructure.Data; using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions; using ErsatzTV.Infrastructure.Extensions;
@ -14,16 +14,16 @@ namespace ErsatzTV.Application.Playouts;
public class CreateExternalJsonPlayoutHandler public class CreateExternalJsonPlayoutHandler
: IRequestHandler<CreateExternalJsonPlayout, Either<BaseError, CreatePlayoutResponse>> : IRequestHandler<CreateExternalJsonPlayout, Either<BaseError, CreatePlayoutResponse>>
{ {
private readonly IFileSystem _fileSystem;
private readonly ChannelWriter<IBackgroundServiceRequest> _channel; private readonly ChannelWriter<IBackgroundServiceRequest> _channel;
private readonly IDbContextFactory<TvContext> _dbContextFactory; private readonly IDbContextFactory<TvContext> _dbContextFactory;
private readonly ILocalFileSystem _localFileSystem;
public CreateExternalJsonPlayoutHandler( public CreateExternalJsonPlayoutHandler(
ILocalFileSystem localFileSystem, IFileSystem fileSystem,
ChannelWriter<IBackgroundServiceRequest> channel, ChannelWriter<IBackgroundServiceRequest> channel,
IDbContextFactory<TvContext> dbContextFactory) IDbContextFactory<TvContext> dbContextFactory)
{ {
_localFileSystem = localFileSystem; _fileSystem = fileSystem;
_channel = channel; _channel = channel;
_dbContextFactory = dbContextFactory; _dbContextFactory = dbContextFactory;
} }
@ -76,7 +76,7 @@ public class CreateExternalJsonPlayoutHandler
private Validation<BaseError, string> ValidateExternalJsonFile(CreateExternalJsonPlayout request) private Validation<BaseError, string> ValidateExternalJsonFile(CreateExternalJsonPlayout request)
{ {
if (!_localFileSystem.FileExists(request.ScheduleFile)) if (!_fileSystem.File.Exists(request.ScheduleFile))
{ {
return BaseError.New("External Json File does not exist!"); return BaseError.New("External Json File does not exist!");
} }

30
ErsatzTV.Application/Playouts/Commands/CreateScriptedPlayoutHandler.cs

@ -1,4 +1,5 @@
using System.CommandLine.Parsing; using System.CommandLine.Parsing;
using System.IO.Abstractions;
using System.Threading.Channels; using System.Threading.Channels;
using ErsatzTV.Application.Channels; using ErsatzTV.Application.Channels;
using ErsatzTV.Core; using ErsatzTV.Core;
@ -12,28 +13,17 @@ using Channel = ErsatzTV.Core.Domain.Channel;
namespace ErsatzTV.Application.Playouts; namespace ErsatzTV.Application.Playouts;
public class CreateScriptedPlayoutHandler public class CreateScriptedPlayoutHandler(
IFileSystem fileSystem,
ChannelWriter<IBackgroundServiceRequest> channel,
IDbContextFactory<TvContext> dbContextFactory)
: IRequestHandler<CreateScriptedPlayout, Either<BaseError, CreatePlayoutResponse>> : IRequestHandler<CreateScriptedPlayout, Either<BaseError, CreatePlayoutResponse>>
{ {
private readonly ChannelWriter<IBackgroundServiceRequest> _channel;
private readonly IDbContextFactory<TvContext> _dbContextFactory;
private readonly ILocalFileSystem _localFileSystem;
public CreateScriptedPlayoutHandler(
ILocalFileSystem localFileSystem,
ChannelWriter<IBackgroundServiceRequest> channel,
IDbContextFactory<TvContext> dbContextFactory)
{
_localFileSystem = localFileSystem;
_channel = channel;
_dbContextFactory = dbContextFactory;
}
public async Task<Either<BaseError, CreatePlayoutResponse>> Handle( public async Task<Either<BaseError, CreatePlayoutResponse>> Handle(
CreateScriptedPlayout request, CreateScriptedPlayout request,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, Playout> validation = await Validate(dbContext, request, cancellationToken); Validation<BaseError, Playout> validation = await Validate(dbContext, request, cancellationToken);
return await validation.Apply(playout => PersistPlayout(dbContext, playout, cancellationToken)); return await validation.Apply(playout => PersistPlayout(dbContext, playout, cancellationToken));
} }
@ -45,15 +35,15 @@ public class CreateScriptedPlayoutHandler
{ {
await dbContext.Playouts.AddAsync(playout, cancellationToken); await dbContext.Playouts.AddAsync(playout, cancellationToken);
await dbContext.SaveChangesAsync(cancellationToken); await dbContext.SaveChangesAsync(cancellationToken);
await _channel.WriteAsync(new BuildPlayout(playout.Id, PlayoutBuildMode.Reset), cancellationToken); await channel.WriteAsync(new BuildPlayout(playout.Id, PlayoutBuildMode.Reset), cancellationToken);
if (playout.Channel.PlayoutMode is ChannelPlayoutMode.OnDemand) if (playout.Channel.PlayoutMode is ChannelPlayoutMode.OnDemand)
{ {
await _channel.WriteAsync( await channel.WriteAsync(
new TimeShiftOnDemandPlayout(playout.Id, DateTimeOffset.Now, false), new TimeShiftOnDemandPlayout(playout.Id, DateTimeOffset.Now, false),
cancellationToken); cancellationToken);
} }
await _channel.WriteAsync(new RefreshChannelList(), cancellationToken); await channel.WriteAsync(new RefreshChannelList(), cancellationToken);
return new CreatePlayoutResponse(playout.Id); return new CreatePlayoutResponse(playout.Id);
} }
@ -91,7 +81,7 @@ public class CreateScriptedPlayoutHandler
{ {
var args = CommandLineParser.SplitCommandLine(request.ScheduleFile).ToList(); var args = CommandLineParser.SplitCommandLine(request.ScheduleFile).ToList();
string scriptFile = args[0]; string scriptFile = args[0];
if (!_localFileSystem.FileExists(scriptFile)) if (!fileSystem.File.Exists(scriptFile))
{ {
return BaseError.New("Scripted schedule does not exist!"); return BaseError.New("Scripted schedule does not exist!");
} }

31
ErsatzTV.Application/Playouts/Commands/CreateSequentialPlayoutHandler.cs

@ -1,8 +1,8 @@
using System.IO.Abstractions;
using System.Threading.Channels; using System.Threading.Channels;
using ErsatzTV.Application.Channels; using ErsatzTV.Application.Channels;
using ErsatzTV.Core; using ErsatzTV.Core;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Scheduling; using ErsatzTV.Core.Scheduling;
using ErsatzTV.Infrastructure.Data; using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions; using ErsatzTV.Infrastructure.Extensions;
@ -11,28 +11,17 @@ using Channel = ErsatzTV.Core.Domain.Channel;
namespace ErsatzTV.Application.Playouts; namespace ErsatzTV.Application.Playouts;
public class CreateSequentialPlayoutHandler public class CreateSequentialPlayoutHandler(
IFileSystem fileSystem,
ChannelWriter<IBackgroundServiceRequest> channel,
IDbContextFactory<TvContext> dbContextFactory)
: IRequestHandler<CreateSequentialPlayout, Either<BaseError, CreatePlayoutResponse>> : IRequestHandler<CreateSequentialPlayout, Either<BaseError, CreatePlayoutResponse>>
{ {
private readonly ChannelWriter<IBackgroundServiceRequest> _channel;
private readonly IDbContextFactory<TvContext> _dbContextFactory;
private readonly ILocalFileSystem _localFileSystem;
public CreateSequentialPlayoutHandler(
ILocalFileSystem localFileSystem,
ChannelWriter<IBackgroundServiceRequest> channel,
IDbContextFactory<TvContext> dbContextFactory)
{
_localFileSystem = localFileSystem;
_channel = channel;
_dbContextFactory = dbContextFactory;
}
public async Task<Either<BaseError, CreatePlayoutResponse>> Handle( public async Task<Either<BaseError, CreatePlayoutResponse>> Handle(
CreateSequentialPlayout request, CreateSequentialPlayout request,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, Playout> validation = await Validate(dbContext, request, cancellationToken); Validation<BaseError, Playout> validation = await Validate(dbContext, request, cancellationToken);
return await validation.Apply(playout => PersistPlayout(dbContext, playout, cancellationToken)); return await validation.Apply(playout => PersistPlayout(dbContext, playout, cancellationToken));
} }
@ -44,15 +33,15 @@ public class CreateSequentialPlayoutHandler
{ {
await dbContext.Playouts.AddAsync(playout, cancellationToken); await dbContext.Playouts.AddAsync(playout, cancellationToken);
await dbContext.SaveChangesAsync(cancellationToken); await dbContext.SaveChangesAsync(cancellationToken);
await _channel.WriteAsync(new BuildPlayout(playout.Id, PlayoutBuildMode.Reset), cancellationToken); await channel.WriteAsync(new BuildPlayout(playout.Id, PlayoutBuildMode.Reset), cancellationToken);
if (playout.Channel.PlayoutMode is ChannelPlayoutMode.OnDemand) if (playout.Channel.PlayoutMode is ChannelPlayoutMode.OnDemand)
{ {
await _channel.WriteAsync( await channel.WriteAsync(
new TimeShiftOnDemandPlayout(playout.Id, DateTimeOffset.Now, false), new TimeShiftOnDemandPlayout(playout.Id, DateTimeOffset.Now, false),
cancellationToken); cancellationToken);
} }
await _channel.WriteAsync(new RefreshChannelList(), cancellationToken); await channel.WriteAsync(new RefreshChannelList(), cancellationToken);
return new CreatePlayoutResponse(playout.Id); return new CreatePlayoutResponse(playout.Id);
} }
@ -87,7 +76,7 @@ public class CreateSequentialPlayoutHandler
private Validation<BaseError, string> ValidateYamlFile(CreateSequentialPlayout request) private Validation<BaseError, string> ValidateYamlFile(CreateSequentialPlayout request)
{ {
if (!_localFileSystem.FileExists(request.ScheduleFile)) if (!fileSystem.File.Exists(request.ScheduleFile))
{ {
return BaseError.New("Sequential schedule does not exist!"); return BaseError.New("Sequential schedule does not exist!");
} }

36
ErsatzTV.Application/Playouts/Commands/DeletePlayoutHandler.cs

@ -1,8 +1,8 @@
using System.Threading.Channels; using System.IO.Abstractions;
using System.Threading.Channels;
using ErsatzTV.Application.Channels; using ErsatzTV.Application.Channels;
using ErsatzTV.Core; using ErsatzTV.Core;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Notifications; using ErsatzTV.Core.Notifications;
using ErsatzTV.Infrastructure.Data; using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions; using ErsatzTV.Infrastructure.Extensions;
@ -10,28 +10,16 @@ using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.Playouts; namespace ErsatzTV.Application.Playouts;
public class DeletePlayoutHandler : IRequestHandler<DeletePlayout, Either<BaseError, Unit>> public class DeletePlayoutHandler(
ChannelWriter<IBackgroundServiceRequest> workerChannel,
IDbContextFactory<TvContext> dbContextFactory,
IFileSystem fileSystem,
IMediator mediator)
: IRequestHandler<DeletePlayout, Either<BaseError, Unit>>
{ {
private readonly IDbContextFactory<TvContext> _dbContextFactory;
private readonly ILocalFileSystem _localFileSystem;
private readonly IMediator _mediator;
private readonly ChannelWriter<IBackgroundServiceRequest> _workerChannel;
public DeletePlayoutHandler(
ChannelWriter<IBackgroundServiceRequest> workerChannel,
IDbContextFactory<TvContext> dbContextFactory,
ILocalFileSystem localFileSystem,
IMediator mediator)
{
_workerChannel = workerChannel;
_dbContextFactory = dbContextFactory;
_localFileSystem = localFileSystem;
_mediator = mediator;
}
public async Task<Either<BaseError, Unit>> Handle(DeletePlayout request, CancellationToken cancellationToken) public async Task<Either<BaseError, Unit>> Handle(DeletePlayout request, CancellationToken cancellationToken)
{ {
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
Option<Playout> maybePlayout = await dbContext.Playouts Option<Playout> maybePlayout = await dbContext.Playouts
.Include(p => p.Channel) .Include(p => p.Channel)
@ -44,15 +32,15 @@ public class DeletePlayoutHandler : IRequestHandler<DeletePlayout, Either<BaseEr
// delete channel data from channel guide cache // delete channel data from channel guide cache
string cacheFile = Path.Combine(FileSystemLayout.ChannelGuideCacheFolder, $"{playout.Channel.Number}.xml"); string cacheFile = Path.Combine(FileSystemLayout.ChannelGuideCacheFolder, $"{playout.Channel.Number}.xml");
if (_localFileSystem.FileExists(cacheFile)) if (fileSystem.File.Exists(cacheFile))
{ {
File.Delete(cacheFile); File.Delete(cacheFile);
} }
// refresh channel list to remove channel that has no playout // refresh channel list to remove channel that has no playout
await _workerChannel.WriteAsync(new RefreshChannelList(), cancellationToken); await workerChannel.WriteAsync(new RefreshChannelList(), cancellationToken);
await _mediator.Publish(new PlayoutUpdatedNotification(playout.Id, false), cancellationToken); await mediator.Publish(new PlayoutUpdatedNotification(playout.Id, false), cancellationToken);
} }
return maybePlayout return maybePlayout

6
ErsatzTV.Application/Playouts/Commands/UpdateScriptedPlayoutHandler.cs

@ -1,9 +1,9 @@
using System.CommandLine.Parsing; using System.CommandLine.Parsing;
using System.IO.Abstractions;
using System.Threading.Channels; using System.Threading.Channels;
using ErsatzTV.Application.Channels; using ErsatzTV.Application.Channels;
using ErsatzTV.Core; using ErsatzTV.Core;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Infrastructure.Data; using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions; using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
@ -14,7 +14,7 @@ public class
UpdateScriptedPlayoutHandler( UpdateScriptedPlayoutHandler(
IDbContextFactory<TvContext> dbContextFactory, IDbContextFactory<TvContext> dbContextFactory,
ChannelWriter<IBackgroundServiceRequest> workerChannel, ChannelWriter<IBackgroundServiceRequest> workerChannel,
ILocalFileSystem localFileSystem) IFileSystem fileSystem)
: IRequestHandler<UpdateScriptedPlayout, : IRequestHandler<UpdateScriptedPlayout,
Either<BaseError, PlayoutNameViewModel>> Either<BaseError, PlayoutNameViewModel>>
{ {
@ -63,7 +63,7 @@ public class
{ {
var args = CommandLineParser.SplitCommandLine(request.ScheduleFile).ToList(); var args = CommandLineParser.SplitCommandLine(request.ScheduleFile).ToList();
string scriptFile = args[0]; string scriptFile = args[0];
if (!localFileSystem.FileExists(scriptFile)) if (!fileSystem.File.Exists(scriptFile))
{ {
return BaseError.New("Scripted schedule does not exist!"); return BaseError.New("Scripted schedule does not exist!");
} }

7
ErsatzTV.Application/Streaming/Commands/StartFFmpegSessionHandler.cs

@ -1,4 +1,5 @@
using System.Threading.Channels; using System.IO.Abstractions;
using System.Threading.Channels;
using Bugsnag; using Bugsnag;
using ErsatzTV.Application.Channels; using ErsatzTV.Application.Channels;
using ErsatzTV.Application.Graphics; using ErsatzTV.Application.Graphics;
@ -21,6 +22,7 @@ namespace ErsatzTV.Application.Streaming;
public class StartFFmpegSessionHandler : IRequestHandler<StartFFmpegSession, Either<BaseError, Unit>> public class StartFFmpegSessionHandler : IRequestHandler<StartFFmpegSession, Either<BaseError, Unit>>
{ {
private readonly IClient _client; private readonly IClient _client;
private readonly IFileSystem _fileSystem;
private readonly IConfigElementRepository _configElementRepository; private readonly IConfigElementRepository _configElementRepository;
private readonly IFFmpegSegmenterService _ffmpegSegmenterService; private readonly IFFmpegSegmenterService _ffmpegSegmenterService;
private readonly IGraphicsEngine _graphicsEngine; private readonly IGraphicsEngine _graphicsEngine;
@ -40,6 +42,7 @@ public class StartFFmpegSessionHandler : IRequestHandler<StartFFmpegSession, Eit
IServiceScopeFactory serviceScopeFactory, IServiceScopeFactory serviceScopeFactory,
IMediator mediator, IMediator mediator,
IClient client, IClient client,
IFileSystem fileSystem,
ILocalFileSystem localFileSystem, ILocalFileSystem localFileSystem,
ILogger<StartFFmpegSessionHandler> logger, ILogger<StartFFmpegSessionHandler> logger,
ILogger<HlsSessionWorker> sessionWorkerLogger, ILogger<HlsSessionWorker> sessionWorkerLogger,
@ -54,6 +57,7 @@ public class StartFFmpegSessionHandler : IRequestHandler<StartFFmpegSession, Eit
_serviceScopeFactory = serviceScopeFactory; _serviceScopeFactory = serviceScopeFactory;
_mediator = mediator; _mediator = mediator;
_client = client; _client = client;
_fileSystem = fileSystem;
_localFileSystem = localFileSystem; _localFileSystem = localFileSystem;
_logger = logger; _logger = logger;
_sessionWorkerLogger = sessionWorkerLogger; _sessionWorkerLogger = sessionWorkerLogger;
@ -129,6 +133,7 @@ public class StartFFmpegSessionHandler : IRequestHandler<StartFFmpegSession, Eit
_hlsPlaylistFilter, _hlsPlaylistFilter,
_hlsInitSegmentCache, _hlsInitSegmentCache,
_configElementRepository, _configElementRepository,
_fileSystem,
_localFileSystem, _localFileSystem,
_sessionWorkerLogger, _sessionWorkerLogger,
targetFramerate) targetFramerate)

26
ErsatzTV.Application/Streaming/HlsSessionWorker.cs

@ -1,6 +1,7 @@
using System.Diagnostics; using System.Diagnostics;
using System.Diagnostics.CodeAnalysis; using System.Diagnostics.CodeAnalysis;
using System.Globalization; using System.Globalization;
using System.IO.Abstractions;
using System.IO.Pipelines; using System.IO.Pipelines;
using System.Text; using System.Text;
using System.Timers; using System.Timers;
@ -30,6 +31,7 @@ public class HlsSessionWorker : IHlsSessionWorker
private readonly IHlsInitSegmentCache _hlsInitSegmentCache; private readonly IHlsInitSegmentCache _hlsInitSegmentCache;
private readonly Dictionary<long, int> _discontinuityMap = []; private readonly Dictionary<long, int> _discontinuityMap = [];
private readonly IConfigElementRepository _configElementRepository; private readonly IConfigElementRepository _configElementRepository;
private readonly IFileSystem _fileSystem;
private readonly IGraphicsEngine _graphicsEngine; private readonly IGraphicsEngine _graphicsEngine;
private readonly IHlsPlaylistFilter _hlsPlaylistFilter; private readonly IHlsPlaylistFilter _hlsPlaylistFilter;
private readonly ILocalFileSystem _localFileSystem; private readonly ILocalFileSystem _localFileSystem;
@ -60,6 +62,7 @@ public class HlsSessionWorker : IHlsSessionWorker
IHlsPlaylistFilter hlsPlaylistFilter, IHlsPlaylistFilter hlsPlaylistFilter,
IHlsInitSegmentCache hlsInitSegmentCache, IHlsInitSegmentCache hlsInitSegmentCache,
IConfigElementRepository configElementRepository, IConfigElementRepository configElementRepository,
IFileSystem fileSystem,
ILocalFileSystem localFileSystem, ILocalFileSystem localFileSystem,
ILogger<HlsSessionWorker> logger, ILogger<HlsSessionWorker> logger,
Option<int> targetFramerate) Option<int> targetFramerate)
@ -72,6 +75,7 @@ public class HlsSessionWorker : IHlsSessionWorker
_hlsInitSegmentCache = hlsInitSegmentCache; _hlsInitSegmentCache = hlsInitSegmentCache;
_hlsPlaylistFilter = hlsPlaylistFilter; _hlsPlaylistFilter = hlsPlaylistFilter;
_configElementRepository = configElementRepository; _configElementRepository = configElementRepository;
_fileSystem = fileSystem;
_localFileSystem = localFileSystem; _localFileSystem = localFileSystem;
_logger = logger; _logger = logger;
_targetFramerate = targetFramerate; _targetFramerate = targetFramerate;
@ -308,7 +312,7 @@ public class HlsSessionWorker : IHlsSessionWorker
string playlistFileName = Path.Combine(_workingDirectory, "live.m3u8"); string playlistFileName = Path.Combine(_workingDirectory, "live.m3u8");
_logger.LogDebug("Waiting for playlist to exist"); _logger.LogDebug("Waiting for playlist to exist");
while (!_localFileSystem.FileExists(playlistFileName)) while (!_fileSystem.File.Exists(playlistFileName))
{ {
await Task.Delay(TimeSpan.FromMilliseconds(100), cancellationToken); await Task.Delay(TimeSpan.FromMilliseconds(100), cancellationToken);
} }
@ -679,9 +683,9 @@ public class HlsSessionWorker : IHlsSessionWorker
var generatedAtHash = new System.Collections.Generic.HashSet<long>(); var generatedAtHash = new System.Collections.Generic.HashSet<long>();
// delete old segments // delete old segments
var allSegments = Directory.GetFiles(_workingDirectory, "live*.ts") var allSegments = _fileSystem.Directory.GetFiles(_workingDirectory, "live*.ts")
.Append(Directory.GetFiles(_workingDirectory, "live*.mp4")) .Append(_fileSystem.Directory.GetFiles(_workingDirectory, "live*.mp4"))
.Append(Directory.GetFiles(_workingDirectory, "live*.m4s")) .Append(_fileSystem.Directory.GetFiles(_workingDirectory, "live*.m4s"))
.Map(file => .Map(file =>
{ {
string fileName = Path.GetFileName(file); string fileName = Path.GetFileName(file);
@ -699,7 +703,7 @@ public class HlsSessionWorker : IHlsSessionWorker
}) })
.ToList(); .ToList();
var allInits = Directory.GetFiles(_workingDirectory, "*init.mp4") var allInits = _fileSystem.Directory.GetFiles(_workingDirectory, "*init.mp4")
.Map(file => long.TryParse(Path.GetFileName(file).Split('_')[0], out long generatedAt) && !generatedAtHash.Contains(generatedAt) .Map(file => long.TryParse(Path.GetFileName(file).Split('_')[0], out long generatedAt) && !generatedAtHash.Contains(generatedAt)
? new Segment(file, 0, generatedAt) ? new Segment(file, 0, generatedAt)
: Option<Segment>.None) : Option<Segment>.None)
@ -739,7 +743,7 @@ public class HlsSessionWorker : IHlsSessionWorker
{ {
try try
{ {
File.Delete(segment.File); _fileSystem.File.Delete(segment.File);
} }
catch (IOException) catch (IOException)
{ {
@ -752,12 +756,12 @@ public class HlsSessionWorker : IHlsSessionWorker
private async Task RefreshInits() private async Task RefreshInits()
{ {
var allSegments = Directory.GetFiles(_workingDirectory, "live*.m4s") var allSegments = _fileSystem.Directory.GetFiles(_workingDirectory, "live*.m4s")
.Map(Path.GetFileName) .Map(Path.GetFileName)
.Map(s => s.Split("_")[1]) .Map(s => s.Split("_")[1])
.ToHashSet(); .ToHashSet();
foreach (string file in Directory.GetFiles(_workingDirectory, "*init.mp4")) foreach (string file in _fileSystem.Directory.GetFiles(_workingDirectory, "*init.mp4"))
{ {
string key = Path.GetFileName(file).Split("_")[0]; string key = Path.GetFileName(file).Split("_")[0];
if (allSegments.Contains(key)) if (allSegments.Contains(key))
@ -812,9 +816,9 @@ public class HlsSessionWorker : IHlsSessionWorker
private async Task<Option<string[]>> ReadPlaylistLines(CancellationToken cancellationToken) private async Task<Option<string[]>> ReadPlaylistLines(CancellationToken cancellationToken)
{ {
string fileName = PlaylistFileName(); string fileName = PlaylistFileName();
if (File.Exists(fileName)) if (_fileSystem.File.Exists(fileName))
{ {
return await File.ReadAllLinesAsync(fileName, cancellationToken); return await _fileSystem.File.ReadAllLinesAsync(fileName, cancellationToken);
} }
_logger.LogDebug("Playlist does not exist at expected location {File}", fileName); _logger.LogDebug("Playlist does not exist at expected location {File}", fileName);
@ -824,7 +828,7 @@ public class HlsSessionWorker : IHlsSessionWorker
private async Task WritePlaylist(string playlist, CancellationToken cancellationToken) private async Task WritePlaylist(string playlist, CancellationToken cancellationToken)
{ {
string fileName = PlaylistFileName(); string fileName = PlaylistFileName();
await File.WriteAllTextAsync(fileName, playlist, cancellationToken); await _fileSystem.File.WriteAllTextAsync(fileName, playlist, cancellationToken);
} }
private string PlaylistFileName() => Path.Combine(_workingDirectory, "live.m3u8"); private string PlaylistFileName() => Path.Combine(_workingDirectory, "live.m3u8");

8
ErsatzTV.Application/Streaming/Queries/GetLastPtsTimeHandler.cs

@ -1,11 +1,11 @@
using System.Text; using System.IO.Abstractions;
using System.Text;
using Bugsnag; using Bugsnag;
using CliWrap; using CliWrap;
using ErsatzTV.Core; using ErsatzTV.Core;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.FFmpeg; using ErsatzTV.Core.FFmpeg;
using ErsatzTV.Core.Interfaces.FFmpeg; using ErsatzTV.Core.Interfaces.FFmpeg;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Interfaces.Repositories;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Newtonsoft.Json; using Newtonsoft.Json;
@ -14,7 +14,7 @@ namespace ErsatzTV.Application.Streaming;
public class GetLastPtsTimeHandler( public class GetLastPtsTimeHandler(
IClient client, IClient client,
ILocalFileSystem localFileSystem, IFileSystem fileSystem,
ITempFilePool tempFilePool, ITempFilePool tempFilePool,
IConfigElementRepository configElementRepository, IConfigElementRepository configElementRepository,
ILogger<GetLastPtsTimeHandler> logger) ILogger<GetLastPtsTimeHandler> logger)
@ -168,7 +168,7 @@ public class GetLastPtsTimeHandler(
string playlistFileName = Path.Combine(FileSystemLayout.TranscodeFolder, channelNumber, "live.m3u8"); string playlistFileName = Path.Combine(FileSystemLayout.TranscodeFolder, channelNumber, "live.m3u8");
string playlistContents = string.Empty; string playlistContents = string.Empty;
if (localFileSystem.FileExists(playlistFileName)) if (fileSystem.File.Exists(playlistFileName))
{ {
playlistContents = await File.ReadAllTextAsync(playlistFileName); playlistContents = await File.ReadAllTextAsync(playlistFileName);
} }

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

@ -1,4 +1,5 @@
using CliWrap; using System.IO.Abstractions;
using CliWrap;
using Dapper; using Dapper;
using ErsatzTV.Application.Playouts; using ErsatzTV.Application.Playouts;
using ErsatzTV.Core; using ErsatzTV.Core;
@ -31,6 +32,7 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
private readonly IEmbyPathReplacementService _embyPathReplacementService; private readonly IEmbyPathReplacementService _embyPathReplacementService;
private readonly IExternalJsonPlayoutItemProvider _externalJsonPlayoutItemProvider; private readonly IExternalJsonPlayoutItemProvider _externalJsonPlayoutItemProvider;
private readonly IFFmpegProcessService _ffmpegProcessService; private readonly IFFmpegProcessService _ffmpegProcessService;
private readonly IFileSystem _fileSystem;
private readonly IJellyfinPathReplacementService _jellyfinPathReplacementService; private readonly IJellyfinPathReplacementService _jellyfinPathReplacementService;
private readonly ILocalFileSystem _localFileSystem; private readonly ILocalFileSystem _localFileSystem;
private readonly ILogger<GetPlayoutItemProcessByChannelNumberHandler> _logger; private readonly ILogger<GetPlayoutItemProcessByChannelNumberHandler> _logger;
@ -47,6 +49,7 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
public GetPlayoutItemProcessByChannelNumberHandler( public GetPlayoutItemProcessByChannelNumberHandler(
IDbContextFactory<TvContext> dbContextFactory, IDbContextFactory<TvContext> dbContextFactory,
IFFmpegProcessService ffmpegProcessService, IFFmpegProcessService ffmpegProcessService,
IFileSystem fileSystem,
ILocalFileSystem localFileSystem, ILocalFileSystem localFileSystem,
IExternalJsonPlayoutItemProvider externalJsonPlayoutItemProvider, IExternalJsonPlayoutItemProvider externalJsonPlayoutItemProvider,
IPlexPathReplacementService plexPathReplacementService, IPlexPathReplacementService plexPathReplacementService,
@ -64,6 +67,7 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
: base(dbContextFactory) : base(dbContextFactory)
{ {
_ffmpegProcessService = ffmpegProcessService; _ffmpegProcessService = ffmpegProcessService;
_fileSystem = fileSystem;
_localFileSystem = localFileSystem; _localFileSystem = localFileSystem;
_externalJsonPlayoutItemProvider = externalJsonPlayoutItemProvider; _externalJsonPlayoutItemProvider = externalJsonPlayoutItemProvider;
_plexPathReplacementService = plexPathReplacementService; _plexPathReplacementService = plexPathReplacementService;
@ -775,7 +779,7 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
} }
// check filesystem first // check filesystem first
if (_localFileSystem.FileExists(path)) if (_fileSystem.File.Exists(path))
{ {
if (playoutItem.MediaItem is RemoteStream remoteStream) if (playoutItem.MediaItem is RemoteStream remoteStream)
{ {

6
ErsatzTV.Application/Subtitles/Commands/ExtractEmbeddedShowSubtitlesHandler.cs

@ -1,3 +1,4 @@
using System.IO.Abstractions;
using System.Threading.Channels; using System.Threading.Channels;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Infrastructure.Data; using ErsatzTV.Infrastructure.Data;
@ -7,7 +8,6 @@ using Microsoft.Extensions.Logging;
using Dapper; using Dapper;
using ErsatzTV.Application.Maintenance; using ErsatzTV.Application.Maintenance;
using ErsatzTV.Core; using ErsatzTV.Core;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Interfaces.Repositories;
namespace ErsatzTV.Application.Subtitles; namespace ErsatzTV.Application.Subtitles;
@ -16,9 +16,9 @@ public class ExtractEmbeddedShowSubtitlesHandler(
IDbContextFactory<TvContext> dbContextFactory, IDbContextFactory<TvContext> dbContextFactory,
ChannelWriter<IBackgroundServiceRequest> workerChannel, ChannelWriter<IBackgroundServiceRequest> workerChannel,
IConfigElementRepository configElementRepository, IConfigElementRepository configElementRepository,
ILocalFileSystem localFileSystem, IFileSystem fileSystem,
ILogger<ExtractEmbeddedSubtitlesHandler> logger) ILogger<ExtractEmbeddedSubtitlesHandler> logger)
: ExtractEmbeddedSubtitlesHandlerBase(localFileSystem, logger), : ExtractEmbeddedSubtitlesHandlerBase(fileSystem, logger),
IRequestHandler<ExtractEmbeddedShowSubtitles, Option<BaseError>> IRequestHandler<ExtractEmbeddedShowSubtitles, Option<BaseError>>
{ {
public async Task<Option<BaseError>> Handle( public async Task<Option<BaseError>> Handle(

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

@ -1,9 +1,9 @@
using System.Threading.Channels; using System.IO.Abstractions;
using System.Threading.Channels;
using ErsatzTV.Application.Maintenance; using ErsatzTV.Application.Maintenance;
using ErsatzTV.Core; using ErsatzTV.Core;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Locking; using ErsatzTV.Core.Interfaces.Locking;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Infrastructure.Data; using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions; using ErsatzTV.Infrastructure.Extensions;
@ -23,12 +23,12 @@ public class ExtractEmbeddedSubtitlesHandler : ExtractEmbeddedSubtitlesHandlerBa
public ExtractEmbeddedSubtitlesHandler( public ExtractEmbeddedSubtitlesHandler(
IDbContextFactory<TvContext> dbContextFactory, IDbContextFactory<TvContext> dbContextFactory,
ILocalFileSystem localFileSystem, IFileSystem fileSystem,
IEntityLocker entityLocker, IEntityLocker entityLocker,
IConfigElementRepository configElementRepository, IConfigElementRepository configElementRepository,
ChannelWriter<IBackgroundServiceRequest> workerChannel, ChannelWriter<IBackgroundServiceRequest> workerChannel,
ILogger<ExtractEmbeddedSubtitlesHandler> logger) ILogger<ExtractEmbeddedSubtitlesHandler> logger)
: base(localFileSystem, logger) : base(fileSystem, logger)
{ {
_dbContextFactory = dbContextFactory; _dbContextFactory = dbContextFactory;
_entityLocker = entityLocker; _entityLocker = entityLocker;

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

@ -1,4 +1,5 @@
using System.Diagnostics.CodeAnalysis; using System.Diagnostics.CodeAnalysis;
using System.IO.Abstractions;
using System.Security.Cryptography; using System.Security.Cryptography;
using System.Text; using System.Text;
using CliWrap; using CliWrap;
@ -8,7 +9,6 @@ using Dapper;
using ErsatzTV.Core; using ErsatzTV.Core;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Extensions; using ErsatzTV.Core.Extensions;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Infrastructure.Data; using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions; using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
@ -17,7 +17,7 @@ using Microsoft.Extensions.Logging;
namespace ErsatzTV.Application.Subtitles; namespace ErsatzTV.Application.Subtitles;
[SuppressMessage("Security", "CA5351:Do Not Use Broken Cryptographic Algorithms")] [SuppressMessage("Security", "CA5351:Do Not Use Broken Cryptographic Algorithms")]
public abstract class ExtractEmbeddedSubtitlesHandlerBase(ILocalFileSystem localFileSystem, ILogger logger) public abstract class ExtractEmbeddedSubtitlesHandlerBase(IFileSystem fileSystem, ILogger logger)
{ {
protected static Task<Validation<BaseError, string>> FFmpegPathMustExist( protected static Task<Validation<BaseError, string>> FFmpegPathMustExist(
TvContext dbContext, TvContext dbContext,
@ -133,7 +133,7 @@ public abstract class ExtractEmbeddedSubtitlesHandlerBase(ILocalFileSystem local
{ {
string fullOutputPath = Path.Combine(FileSystemLayout.SubtitleCacheFolder, subtitle.OutputPath); string fullOutputPath = Path.Combine(FileSystemLayout.SubtitleCacheFolder, subtitle.OutputPath);
Directory.CreateDirectory(Path.GetDirectoryName(fullOutputPath)); Directory.CreateDirectory(Path.GetDirectoryName(fullOutputPath));
if (localFileSystem.FileExists(fullOutputPath)) if (fileSystem.File.Exists(fullOutputPath))
{ {
File.Delete(fullOutputPath); File.Delete(fullOutputPath);
} }
@ -193,7 +193,7 @@ public abstract class ExtractEmbeddedSubtitlesHandlerBase(ILocalFileSystem local
} }
string fullOutputPath = Path.Combine(FileSystemLayout.FontsCacheFolder, fontStream.FileName); string fullOutputPath = Path.Combine(FileSystemLayout.FontsCacheFolder, fontStream.FileName);
if (localFileSystem.FileExists(fullOutputPath)) if (fileSystem.File.Exists(fullOutputPath))
{ {
// already extracted // already extracted
continue; continue;
@ -212,7 +212,7 @@ public abstract class ExtractEmbeddedSubtitlesHandlerBase(ILocalFileSystem local
// ffmpeg seems to return exit code 1 in all cases when dumping an attachment // ffmpeg seems to return exit code 1 in all cases when dumping an attachment
// so ignore it and check success a different way // so ignore it and check success a different way
if (localFileSystem.FileExists(fullOutputPath)) if (fileSystem.File.Exists(fullOutputPath))
{ {
logger.LogDebug("Successfully extracted font {Font}", fontStream.FileName); logger.LogDebug("Successfully extracted font {Font}", fontStream.FileName);
} }
@ -300,7 +300,7 @@ public abstract class ExtractEmbeddedSubtitlesHandlerBase(ILocalFileSystem local
{ {
foreach (string path in GetRelativeOutputPath(mediaItemId, subtitle)) foreach (string path in GetRelativeOutputPath(mediaItemId, subtitle))
{ {
return !localFileSystem.FileExists(path); return !fileSystem.File.Exists(path);
} }
return false; return false;

4
ErsatzTV.Application/Troubleshooting/Commands/PrepareTroubleshootingPlaybackHandler.cs

@ -1,3 +1,4 @@
using System.IO.Abstractions;
using Dapper; using Dapper;
using ErsatzTV.Application.Streaming; using ErsatzTV.Application.Streaming;
using ErsatzTV.Core; using ErsatzTV.Core;
@ -30,6 +31,7 @@ public class PrepareTroubleshootingPlaybackHandler(
IJellyfinPathReplacementService jellyfinPathReplacementService, IJellyfinPathReplacementService jellyfinPathReplacementService,
IEmbyPathReplacementService embyPathReplacementService, IEmbyPathReplacementService embyPathReplacementService,
IFFmpegProcessService ffmpegProcessService, IFFmpegProcessService ffmpegProcessService,
IFileSystem fileSystem,
ILocalFileSystem localFileSystem, ILocalFileSystem localFileSystem,
ISongVideoGenerator songVideoGenerator, ISongVideoGenerator songVideoGenerator,
IWatermarkSelector watermarkSelector, IWatermarkSelector watermarkSelector,
@ -471,7 +473,7 @@ public class PrepareTroubleshootingPlaybackHandler(
string path = await GetLocalPath(mediaItem, cancellationToken); string path = await GetLocalPath(mediaItem, cancellationToken);
// check filesystem first // check filesystem first
if (localFileSystem.FileExists(path)) if (fileSystem.File.Exists(path))
{ {
if (mediaItem is RemoteStream remoteStream) if (mediaItem is RemoteStream remoteStream)
{ {

1
ErsatzTV.Core.Tests/ErsatzTV.Core.Tests.csproj

@ -27,6 +27,7 @@
<PackageReference Include="Serilog.Extensions.Logging" Version="9.0.2" /> <PackageReference Include="Serilog.Extensions.Logging" Version="9.0.2" />
<PackageReference Include="Serilog.Sinks.Debug" Version="3.0.0" /> <PackageReference Include="Serilog.Sinks.Debug" Version="3.0.0" />
<PackageReference Include="Shouldly" Version="4.3.0" /> <PackageReference Include="Shouldly" Version="4.3.0" />
<PackageReference Include="Testably.Abstractions.Testing" Version="5.0.0" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>

177
ErsatzTV.Core.Tests/FFmpeg/CustomStreamSelectorTests.cs

@ -2,11 +2,11 @@ using Destructurama;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.FFmpeg; using ErsatzTV.Core.FFmpeg;
using ErsatzTV.Core.Interfaces.FFmpeg; using ErsatzTV.Core.Interfaces.FFmpeg;
using ErsatzTV.Core.Tests.Fakes;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using NUnit.Framework; using NUnit.Framework;
using Serilog; using Serilog;
using Shouldly; using Shouldly;
using Testably.Abstractions.Testing;
namespace ErsatzTV.Core.Tests.FFmpeg; namespace ErsatzTV.Core.Tests.FFmpeg;
@ -94,9 +94,10 @@ public class CustomStreamSelectorTests
- "eng" - "eng"
"""; """;
var streamSelector = new CustomStreamSelector( var fileSystem = new MockFileSystem();
new FakeLocalFileSystem([new FakeFileEntry(TestFileName) { Contents = YAML }]), fileSystem.Initialize()
_logger); .WithFile(TestFileName).Which(f => f.HasStringContent(YAML));
var streamSelector = new CustomStreamSelector(fileSystem, _logger);
StreamSelectorResult result = await streamSelector.SelectStreams( StreamSelectorResult result = await streamSelector.SelectStreams(
_channel, _channel,
@ -123,9 +124,10 @@ public class CustomStreamSelectorTests
- audio_language: ["und"] - audio_language: ["und"]
"""; """;
var streamSelector = new CustomStreamSelector( var fileSystem = new MockFileSystem();
new FakeLocalFileSystem([new FakeFileEntry(TestFileName) { Contents = YAML }]), fileSystem.Initialize()
_logger); .WithFile(TestFileName).Which(f => f.HasStringContent(YAML));
var streamSelector = new CustomStreamSelector(fileSystem, _logger);
StreamSelectorResult result = await streamSelector.SelectStreams( StreamSelectorResult result = await streamSelector.SelectStreams(
_channel, _channel,
@ -152,9 +154,10 @@ public class CustomStreamSelectorTests
- audio_language: ["en", "eng"] - audio_language: ["en", "eng"]
"""; """;
var streamSelector = new CustomStreamSelector( var fileSystem = new MockFileSystem();
new FakeLocalFileSystem([new FakeFileEntry(TestFileName) { Contents = YAML }]), fileSystem.Initialize()
_logger); .WithFile(TestFileName).Which(f => f.HasStringContent(YAML));
var streamSelector = new CustomStreamSelector(fileSystem, _logger);
StreamSelectorResult result = await streamSelector.SelectStreams( StreamSelectorResult result = await streamSelector.SelectStreams(
_channel, _channel,
@ -187,9 +190,10 @@ public class CustomStreamSelectorTests
disable_subtitles: true disable_subtitles: true
"""; """;
var streamSelector = new CustomStreamSelector( var fileSystem = new MockFileSystem();
new FakeLocalFileSystem([new FakeFileEntry(TestFileName) { Contents = YAML }]), fileSystem.Initialize()
_logger); .WithFile(TestFileName).Which(f => f.HasStringContent(YAML));
var streamSelector = new CustomStreamSelector(fileSystem, _logger);
StreamSelectorResult result = await streamSelector.SelectStreams( StreamSelectorResult result = await streamSelector.SelectStreams(
_channel, _channel,
@ -217,9 +221,10 @@ public class CustomStreamSelectorTests
- "en*" - "en*"
"""; """;
var streamSelector = new CustomStreamSelector( var fileSystem = new MockFileSystem();
new FakeLocalFileSystem([new FakeFileEntry(TestFileName) { Contents = YAML }]), fileSystem.Initialize()
_logger); .WithFile(TestFileName).Which(f => f.HasStringContent(YAML));
var streamSelector = new CustomStreamSelector(fileSystem, _logger);
StreamSelectorResult result = await streamSelector.SelectStreams( StreamSelectorResult result = await streamSelector.SelectStreams(
_channel, _channel,
@ -248,9 +253,10 @@ public class CustomStreamSelectorTests
"""; """;
_audioVersion = GetTestAudioVersion("en"); _audioVersion = GetTestAudioVersion("en");
var streamSelector = new CustomStreamSelector( var fileSystem = new MockFileSystem();
new FakeLocalFileSystem([new FakeFileEntry(TestFileName) { Contents = YAML }]), fileSystem.Initialize()
_logger); .WithFile(TestFileName).Which(f => f.HasStringContent(YAML));
var streamSelector = new CustomStreamSelector(fileSystem, _logger);
StreamSelectorResult result = await streamSelector.SelectStreams( StreamSelectorResult result = await streamSelector.SelectStreams(
_channel, _channel,
@ -279,9 +285,10 @@ public class CustomStreamSelectorTests
disable_subtitles: true disable_subtitles: true
"""; """;
var streamSelector = new CustomStreamSelector( var fileSystem = new MockFileSystem();
new FakeLocalFileSystem([new FakeFileEntry(TestFileName) { Contents = YAML }]), fileSystem.Initialize()
_logger); .WithFile(TestFileName).Which(f => f.HasStringContent(YAML));
var streamSelector = new CustomStreamSelector(fileSystem, _logger);
StreamSelectorResult result = await streamSelector.SelectStreams( StreamSelectorResult result = await streamSelector.SelectStreams(
_channel, _channel,
@ -305,9 +312,10 @@ public class CustomStreamSelectorTests
- "eng" - "eng"
"""; """;
var streamSelector = new CustomStreamSelector( var fileSystem = new MockFileSystem();
new FakeLocalFileSystem([new FakeFileEntry(TestFileName) { Contents = YAML }]), fileSystem.Initialize()
_logger); .WithFile(TestFileName).Which(f => f.HasStringContent(YAML));
var streamSelector = new CustomStreamSelector(fileSystem, _logger);
StreamSelectorResult result = await streamSelector.SelectStreams( StreamSelectorResult result = await streamSelector.SelectStreams(
_channel, _channel,
@ -337,9 +345,10 @@ public class CustomStreamSelectorTests
- "en*" - "en*"
"""; """;
var streamSelector = new CustomStreamSelector( var fileSystem = new MockFileSystem();
new FakeLocalFileSystem([new FakeFileEntry(TestFileName) { Contents = YAML }]), fileSystem.Initialize()
_logger); .WithFile(TestFileName).Which(f => f.HasStringContent(YAML));
var streamSelector = new CustomStreamSelector(fileSystem, _logger);
StreamSelectorResult result = await streamSelector.SelectStreams( StreamSelectorResult result = await streamSelector.SelectStreams(
_channel, _channel,
@ -378,9 +387,10 @@ public class CustomStreamSelectorTests
} }
]; ];
var streamSelector = new CustomStreamSelector( var fileSystem = new MockFileSystem();
new FakeLocalFileSystem([new FakeFileEntry(TestFileName) { Contents = YAML }]), fileSystem.Initialize()
_logger); .WithFile(TestFileName).Which(f => f.HasStringContent(YAML));
var streamSelector = new CustomStreamSelector(fileSystem, _logger);
StreamSelectorResult result = await streamSelector.SelectStreams( StreamSelectorResult result = await streamSelector.SelectStreams(
_channel, _channel,
@ -413,9 +423,10 @@ public class CustomStreamSelectorTests
disable_subtitles: true disable_subtitles: true
"""; """;
var streamSelector = new CustomStreamSelector( var fileSystem = new MockFileSystem();
new FakeLocalFileSystem([new FakeFileEntry(TestFileName) { Contents = YAML }]), fileSystem.Initialize()
_logger); .WithFile(TestFileName).Which(f => f.HasStringContent(YAML));
var streamSelector = new CustomStreamSelector(fileSystem, _logger);
StreamSelectorResult result = await streamSelector.SelectStreams( StreamSelectorResult result = await streamSelector.SelectStreams(
_channel, _channel,
@ -450,9 +461,10 @@ public class CustomStreamSelectorTests
disable_subtitles: true disable_subtitles: true
"""; """;
var streamSelector = new CustomStreamSelector( var fileSystem = new MockFileSystem();
new FakeLocalFileSystem([new FakeFileEntry(TestFileName) { Contents = YAML }]), fileSystem.Initialize()
_logger); .WithFile(TestFileName).Which(f => f.HasStringContent(YAML));
var streamSelector = new CustomStreamSelector(fileSystem, _logger);
StreamSelectorResult result = await streamSelector.SelectStreams( StreamSelectorResult result = await streamSelector.SelectStreams(
_channel, _channel,
@ -492,9 +504,10 @@ public class CustomStreamSelectorTests
disable_subtitles: true disable_subtitles: true
"""; """;
var streamSelector = new CustomStreamSelector( var fileSystem = new MockFileSystem();
new FakeLocalFileSystem([new FakeFileEntry(TestFileName) { Contents = YAML }]), fileSystem.Initialize()
_logger); .WithFile(TestFileName).Which(f => f.HasStringContent(YAML));
var streamSelector = new CustomStreamSelector(fileSystem, _logger);
StreamSelectorResult result = await streamSelector.SelectStreams( StreamSelectorResult result = await streamSelector.SelectStreams(
_channel, _channel,
@ -528,9 +541,10 @@ public class CustomStreamSelectorTests
disable_subtitles: true disable_subtitles: true
"""; """;
var streamSelector = new CustomStreamSelector( var fileSystem = new MockFileSystem();
new FakeLocalFileSystem([new FakeFileEntry(TestFileName) { Contents = YAML }]), fileSystem.Initialize()
_logger); .WithFile(TestFileName).Which(f => f.HasStringContent(YAML));
var streamSelector = new CustomStreamSelector(fileSystem, _logger);
StreamSelectorResult result = await streamSelector.SelectStreams( StreamSelectorResult result = await streamSelector.SelectStreams(
_channel, _channel,
@ -568,9 +582,10 @@ public class CustomStreamSelectorTests
- "riff" - "riff"
"""; """;
var streamSelector = new CustomStreamSelector( var fileSystem = new MockFileSystem();
new FakeLocalFileSystem([new FakeFileEntry(TestFileName) { Contents = YAML }]), fileSystem.Initialize()
_logger); .WithFile(TestFileName).Which(f => f.HasStringContent(YAML));
var streamSelector = new CustomStreamSelector(fileSystem, _logger);
StreamSelectorResult result = await streamSelector.SelectStreams( StreamSelectorResult result = await streamSelector.SelectStreams(
_channel, _channel,
@ -600,9 +615,10 @@ public class CustomStreamSelectorTests
- "movie" - "movie"
"""; """;
var streamSelector = new CustomStreamSelector( var fileSystem = new MockFileSystem();
new FakeLocalFileSystem([new FakeFileEntry(TestFileName) { Contents = YAML }]), fileSystem.Initialize()
_logger); .WithFile(TestFileName).Which(f => f.HasStringContent(YAML));
var streamSelector = new CustomStreamSelector(fileSystem, _logger);
StreamSelectorResult result = await streamSelector.SelectStreams( StreamSelectorResult result = await streamSelector.SelectStreams(
_channel, _channel,
@ -634,9 +650,10 @@ public class CustomStreamSelectorTests
- "signs" - "signs"
"""; """;
var streamSelector = new CustomStreamSelector( var fileSystem = new MockFileSystem();
new FakeLocalFileSystem([new FakeFileEntry(TestFileName) { Contents = YAML }]), fileSystem.Initialize()
_logger); .WithFile(TestFileName).Which(f => f.HasStringContent(YAML));
var streamSelector = new CustomStreamSelector(fileSystem, _logger);
StreamSelectorResult result = await streamSelector.SelectStreams( StreamSelectorResult result = await streamSelector.SelectStreams(
_channel, _channel,
@ -668,9 +685,10 @@ public class CustomStreamSelectorTests
- "songs" - "songs"
"""; """;
var streamSelector = new CustomStreamSelector( var fileSystem = new MockFileSystem();
new FakeLocalFileSystem([new FakeFileEntry(TestFileName) { Contents = YAML }]), fileSystem.Initialize()
_logger); .WithFile(TestFileName).Which(f => f.HasStringContent(YAML));
var streamSelector = new CustomStreamSelector(fileSystem, _logger);
StreamSelectorResult result = await streamSelector.SelectStreams( StreamSelectorResult result = await streamSelector.SelectStreams(
_channel, _channel,
@ -699,9 +717,10 @@ public class CustomStreamSelectorTests
subtitle_condition: "forced" subtitle_condition: "forced"
"""; """;
var streamSelector = new CustomStreamSelector( var fileSystem = new MockFileSystem();
new FakeLocalFileSystem([new FakeFileEntry(TestFileName) { Contents = YAML }]), fileSystem.Initialize()
_logger); .WithFile(TestFileName).Which(f => f.HasStringContent(YAML));
var streamSelector = new CustomStreamSelector(fileSystem, _logger);
StreamSelectorResult result = await streamSelector.SelectStreams( StreamSelectorResult result = await streamSelector.SelectStreams(
_channel, _channel,
@ -730,9 +749,10 @@ public class CustomStreamSelectorTests
subtitle_condition: "lang like 'en%' and external" subtitle_condition: "lang like 'en%' and external"
"""; """;
var streamSelector = new CustomStreamSelector( var fileSystem = new MockFileSystem();
new FakeLocalFileSystem([new FakeFileEntry(TestFileName) { Contents = YAML }]), fileSystem.Initialize()
_logger); .WithFile(TestFileName).Which(f => f.HasStringContent(YAML));
var streamSelector = new CustomStreamSelector(fileSystem, _logger);
StreamSelectorResult result = await streamSelector.SelectStreams( StreamSelectorResult result = await streamSelector.SelectStreams(
_channel, _channel,
@ -761,9 +781,10 @@ public class CustomStreamSelectorTests
audio_condition: "title like '%movie%'" audio_condition: "title like '%movie%'"
"""; """;
var streamSelector = new CustomStreamSelector( var fileSystem = new MockFileSystem();
new FakeLocalFileSystem([new FakeFileEntry(TestFileName) { Contents = YAML }]), fileSystem.Initialize()
_logger); .WithFile(TestFileName).Which(f => f.HasStringContent(YAML));
var streamSelector = new CustomStreamSelector(fileSystem, _logger);
StreamSelectorResult result = await streamSelector.SelectStreams( StreamSelectorResult result = await streamSelector.SelectStreams(
_channel, _channel,
@ -792,9 +813,10 @@ public class CustomStreamSelectorTests
audio_condition: "channels > 2" audio_condition: "channels > 2"
"""; """;
var streamSelector = new CustomStreamSelector( var fileSystem = new MockFileSystem();
new FakeLocalFileSystem([new FakeFileEntry(TestFileName) { Contents = YAML }]), fileSystem.Initialize()
_logger); .WithFile(TestFileName).Which(f => f.HasStringContent(YAML));
var streamSelector = new CustomStreamSelector(fileSystem, _logger);
StreamSelectorResult result = await streamSelector.SelectStreams( StreamSelectorResult result = await streamSelector.SelectStreams(
_channel, _channel,
@ -822,9 +844,10 @@ public class CustomStreamSelectorTests
audio_title_blocklist: ["riff"] audio_title_blocklist: ["riff"]
"""; """;
var streamSelector = new CustomStreamSelector( var fileSystem = new MockFileSystem();
new FakeLocalFileSystem([new FakeFileEntry(TestFileName) { Contents = YAML }]), fileSystem.Initialize()
_logger); .WithFile(TestFileName).Which(f => f.HasStringContent(YAML));
var streamSelector = new CustomStreamSelector(fileSystem, _logger);
StreamSelectorResult result = await streamSelector.SelectStreams( StreamSelectorResult result = await streamSelector.SelectStreams(
_channel, _channel,
@ -853,9 +876,10 @@ public class CustomStreamSelectorTests
subtitle_language: ["jp","en*"] subtitle_language: ["jp","en*"]
"""; """;
var streamSelector = new CustomStreamSelector( var fileSystem = new MockFileSystem();
new FakeLocalFileSystem([new FakeFileEntry(TestFileName) { Contents = YAML }]), fileSystem.Initialize()
_logger); .WithFile(TestFileName).Which(f => f.HasStringContent(YAML));
var streamSelector = new CustomStreamSelector(fileSystem, _logger);
StreamSelectorResult result = await streamSelector.SelectStreams( StreamSelectorResult result = await streamSelector.SelectStreams(
_channel, _channel,
@ -885,9 +909,10 @@ public class CustomStreamSelectorTests
subtitle_language: ["es*","de*"] subtitle_language: ["es*","de*"]
"""; """;
var streamSelector = new CustomStreamSelector( var fileSystem = new MockFileSystem();
new FakeLocalFileSystem([new FakeFileEntry(TestFileName) { Contents = YAML }]), fileSystem.Initialize()
_logger); .WithFile(TestFileName).Which(f => f.HasStringContent(YAML));
var streamSelector = new CustomStreamSelector(fileSystem, _logger);
StreamSelectorResult result = await streamSelector.SelectStreams( StreamSelectorResult result = await streamSelector.SelectStreams(
_channel, _channel,

7
ErsatzTV.Core.Tests/FFmpeg/FFmpegStreamSelectorTests.cs

@ -8,6 +8,7 @@ using Microsoft.Extensions.Logging;
using NSubstitute; using NSubstitute;
using NUnit.Framework; using NUnit.Framework;
using Shouldly; using Shouldly;
using Testably.Abstractions.Testing;
namespace ErsatzTV.Core.Tests.FFmpeg; namespace ErsatzTV.Core.Tests.FFmpeg;
@ -61,7 +62,7 @@ public class FFmpegStreamSelectorTests
new ScriptEngine(Substitute.For<ILogger<ScriptEngine>>()), new ScriptEngine(Substitute.For<ILogger<ScriptEngine>>()),
Substitute.For<IStreamSelectorRepository>(), Substitute.For<IStreamSelectorRepository>(),
Substitute.For<IConfigElementRepository>(), Substitute.For<IConfigElementRepository>(),
Substitute.For<ILocalFileSystem>(), new MockFileSystem(),
languageCodeService, languageCodeService,
Substitute.For<ILogger<FFmpegStreamSelector>>()); Substitute.For<ILogger<FFmpegStreamSelector>>());
@ -123,7 +124,7 @@ public class FFmpegStreamSelectorTests
new ScriptEngine(Substitute.For<ILogger<ScriptEngine>>()), new ScriptEngine(Substitute.For<ILogger<ScriptEngine>>()),
Substitute.For<IStreamSelectorRepository>(), Substitute.For<IStreamSelectorRepository>(),
Substitute.For<IConfigElementRepository>(), Substitute.For<IConfigElementRepository>(),
Substitute.For<ILocalFileSystem>(), new MockFileSystem(),
languageCodeService, languageCodeService,
Substitute.For<ILogger<FFmpegStreamSelector>>()); Substitute.For<ILogger<FFmpegStreamSelector>>());
@ -173,7 +174,7 @@ public class FFmpegStreamSelectorTests
new ScriptEngine(Substitute.For<ILogger<ScriptEngine>>()), new ScriptEngine(Substitute.For<ILogger<ScriptEngine>>()),
Substitute.For<IStreamSelectorRepository>(), Substitute.For<IStreamSelectorRepository>(),
Substitute.For<IConfigElementRepository>(), Substitute.For<IConfigElementRepository>(),
Substitute.For<ILocalFileSystem>(), new MockFileSystem(),
languageCodeService, languageCodeService,
Substitute.For<ILogger<FFmpegStreamSelector>>()); Substitute.For<ILogger<FFmpegStreamSelector>>());

8
ErsatzTV.Core.Tests/Fakes/FakeFileEntry.cs

@ -1,8 +0,0 @@
namespace ErsatzTV.Core.Tests.Fakes;
public record FakeFileEntry(string Path)
{
public DateTime LastWriteTime { get; set; } = SystemTime.MinValueUtc;
public string Contents { get; set; }
}

3
ErsatzTV.Core.Tests/Fakes/FakeFolderEntry.cs

@ -1,3 +0,0 @@
namespace ErsatzTV.Core.Tests.Fakes;
public record FakeFolderEntry(string Path);

99
ErsatzTV.Core.Tests/Fakes/FakeLocalFileSystem.cs

@ -1,99 +0,0 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Metadata;
namespace ErsatzTV.Core.Tests.Fakes;
public class FakeLocalFileSystem : ILocalFileSystem
{
private readonly List<FakeFileEntry> _files;
private readonly List<FakeFolderEntry> _folders;
public FakeLocalFileSystem(List<FakeFileEntry> files) : this(files, new List<FakeFolderEntry>())
{
}
public FakeLocalFileSystem(List<FakeFileEntry> files, List<FakeFolderEntry> folders)
{
_files = files;
var allFolders = new List<string>(folders.Map(f => f.Path));
foreach (FakeFileEntry file in _files)
{
List<DirectoryInfo> moreFolders =
Split(new DirectoryInfo(Path.GetDirectoryName(file.Path) ?? string.Empty));
allFolders.AddRange(moreFolders.Map(i => i.FullName));
}
_folders = allFolders.Distinct().Map(f => new FakeFolderEntry(f)).ToList();
}
public Unit EnsureFolderExists(string folder) => Unit.Default;
public DateTime GetLastWriteTime(string path) =>
Optional(_files.SingleOrDefault(f => f.Path == path))
.Map(f => f.LastWriteTime)
.IfNone(SystemTime.MinValueUtc);
public bool IsLibraryPathAccessible(LibraryPath libraryPath) =>
_folders.Any(f => f.Path == libraryPath.Path);
public IEnumerable<string> ListSubdirectories(string folder) =>
_folders.Map(f => f.Path).Filter(f => f.StartsWith(folder) && Directory.GetParent(f)?.FullName == folder);
public IEnumerable<string> ListFiles(string folder) =>
_files.Map(f => f.Path).Filter(f => Path.GetDirectoryName(f) == folder);
// TODO: this isn't accurate, need to use search pattern
public IEnumerable<string> ListFiles(string folder, string searchPattern) =>
_files.Map(f => f.Path).Filter(f => Path.GetDirectoryName(f) == folder);
public IEnumerable<string> ListFiles(string folder, params string[] searchPatterns) =>
_files.Map(f => f.Path).Filter(f => Path.GetDirectoryName(f) == folder);
public bool FileExists(string path) => _files.Any(f => f.Path == path);
public bool FolderExists(string folder) => false;
public Task<Either<BaseError, Unit>> CopyFile(string source, string destination) =>
Task.FromResult(Right<BaseError, Unit>(Unit.Default));
public Unit EmptyFolder(string folder) => Unit.Default;
public async Task<string> ReadAllText(string path) => await _files
.Filter(f => f.Path == path)
.HeadOrNone()
.Select(f => f.Contents)
.IfNoneAsync(string.Empty);
public async Task<string[]> ReadAllLines(string path) => await _files
.Filter(f => f.Path == path)
.HeadOrNone()
.Select(f => f.Contents)
.IfNoneAsync(string.Empty)
.Map(s => s.Split(Environment.NewLine));
public Task<byte[]> GetHash(string path) => throw new NotSupportedException();
public string GetCustomOrDefaultFile(string folder, string file)
{
string path = Path.Combine(folder, file);
return FileExists(path) ? path : Path.Combine(folder, $"_{file}");
}
private static List<DirectoryInfo> Split(DirectoryInfo path)
{
var result = new List<DirectoryInfo>();
if (path == null || string.IsNullOrWhiteSpace(path.FullName))
{
return result;
}
if (path.Parent != null)
{
result.AddRange(Split(path.Parent));
}
result.Add(path);
return result;
}
}

11
ErsatzTV.Core.Tests/Scheduling/ClassicScheduling/ContinuePlayoutTests.cs

@ -1,6 +1,5 @@
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Scheduling; using ErsatzTV.Core.Domain.Scheduling;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Scheduling; using ErsatzTV.Core.Interfaces.Scheduling;
using ErsatzTV.Core.Scheduling; using ErsatzTV.Core.Scheduling;
@ -8,6 +7,7 @@ using ErsatzTV.Core.Tests.Fakes;
using NSubstitute; using NSubstitute;
using NUnit.Framework; using NUnit.Framework;
using Shouldly; using Shouldly;
using Testably.Abstractions.Testing;
namespace ErsatzTV.Core.Tests.Scheduling.ClassicScheduling; namespace ErsatzTV.Core.Tests.Scheduling.ClassicScheduling;
@ -588,7 +588,6 @@ public class ContinuePlayoutTests : PlayoutBuilderTestBase
IArtistRepository artistRepo = Substitute.For<IArtistRepository>(); IArtistRepository artistRepo = Substitute.For<IArtistRepository>();
IMultiEpisodeShuffleCollectionEnumeratorFactory factory = IMultiEpisodeShuffleCollectionEnumeratorFactory factory =
Substitute.For<IMultiEpisodeShuffleCollectionEnumeratorFactory>(); Substitute.For<IMultiEpisodeShuffleCollectionEnumeratorFactory>();
ILocalFileSystem localFileSystem = Substitute.For<ILocalFileSystem>();
IRerunHelper rerunHelper = Substitute.For<IRerunHelper>(); IRerunHelper rerunHelper = Substitute.For<IRerunHelper>();
var builder = new PlayoutBuilder( var builder = new PlayoutBuilder(
configRepo, configRepo,
@ -596,7 +595,7 @@ public class ContinuePlayoutTests : PlayoutBuilderTestBase
televisionRepo, televisionRepo,
artistRepo, artistRepo,
factory, factory,
localFileSystem, new MockFileSystem(),
rerunHelper, rerunHelper,
Logger); Logger);
@ -714,7 +713,6 @@ public class ContinuePlayoutTests : PlayoutBuilderTestBase
IArtistRepository artistRepo = Substitute.For<IArtistRepository>(); IArtistRepository artistRepo = Substitute.For<IArtistRepository>();
IMultiEpisodeShuffleCollectionEnumeratorFactory factory = IMultiEpisodeShuffleCollectionEnumeratorFactory factory =
Substitute.For<IMultiEpisodeShuffleCollectionEnumeratorFactory>(); Substitute.For<IMultiEpisodeShuffleCollectionEnumeratorFactory>();
ILocalFileSystem localFileSystem = Substitute.For<ILocalFileSystem>();
IRerunHelper rerunHelper = Substitute.For<IRerunHelper>(); IRerunHelper rerunHelper = Substitute.For<IRerunHelper>();
var builder = new PlayoutBuilder( var builder = new PlayoutBuilder(
configRepo, configRepo,
@ -722,7 +720,7 @@ public class ContinuePlayoutTests : PlayoutBuilderTestBase
televisionRepo, televisionRepo,
artistRepo, artistRepo,
factory, factory,
localFileSystem, new MockFileSystem(),
rerunHelper, rerunHelper,
Logger); Logger);
@ -842,7 +840,6 @@ public class ContinuePlayoutTests : PlayoutBuilderTestBase
IArtistRepository artistRepo = Substitute.For<IArtistRepository>(); IArtistRepository artistRepo = Substitute.For<IArtistRepository>();
IMultiEpisodeShuffleCollectionEnumeratorFactory factory = IMultiEpisodeShuffleCollectionEnumeratorFactory factory =
Substitute.For<IMultiEpisodeShuffleCollectionEnumeratorFactory>(); Substitute.For<IMultiEpisodeShuffleCollectionEnumeratorFactory>();
ILocalFileSystem localFileSystem = Substitute.For<ILocalFileSystem>();
IRerunHelper rerunHelper = Substitute.For<IRerunHelper>(); IRerunHelper rerunHelper = Substitute.For<IRerunHelper>();
var builder = new PlayoutBuilder( var builder = new PlayoutBuilder(
configRepo, configRepo,
@ -850,7 +847,7 @@ public class ContinuePlayoutTests : PlayoutBuilderTestBase
televisionRepo, televisionRepo,
artistRepo, artistRepo,
factory, factory,
localFileSystem, new MockFileSystem(),
rerunHelper, rerunHelper,
Logger); Logger);

35
ErsatzTV.Core.Tests/Scheduling/ClassicScheduling/NewPlayoutTests.cs

@ -1,7 +1,6 @@
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Filler; using ErsatzTV.Core.Domain.Filler;
using ErsatzTV.Core.Domain.Scheduling; using ErsatzTV.Core.Domain.Scheduling;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Scheduling; using ErsatzTV.Core.Interfaces.Scheduling;
using ErsatzTV.Core.Scheduling; using ErsatzTV.Core.Scheduling;
@ -9,6 +8,7 @@ using ErsatzTV.Core.Tests.Fakes;
using NSubstitute; using NSubstitute;
using NUnit.Framework; using NUnit.Framework;
using Shouldly; using Shouldly;
using Testably.Abstractions.Testing;
namespace ErsatzTV.Core.Tests.Scheduling.ClassicScheduling; namespace ErsatzTV.Core.Tests.Scheduling.ClassicScheduling;
@ -597,7 +597,6 @@ public class NewPlayoutTests : PlayoutBuilderTestBase
IArtistRepository artistRepo = Substitute.For<IArtistRepository>(); IArtistRepository artistRepo = Substitute.For<IArtistRepository>();
IMultiEpisodeShuffleCollectionEnumeratorFactory factory = IMultiEpisodeShuffleCollectionEnumeratorFactory factory =
Substitute.For<IMultiEpisodeShuffleCollectionEnumeratorFactory>(); Substitute.For<IMultiEpisodeShuffleCollectionEnumeratorFactory>();
ILocalFileSystem localFileSystem = Substitute.For<ILocalFileSystem>();
IRerunHelper rerunHelper = Substitute.For<IRerunHelper>(); IRerunHelper rerunHelper = Substitute.For<IRerunHelper>();
var builder = new PlayoutBuilder( var builder = new PlayoutBuilder(
configRepo, configRepo,
@ -605,7 +604,7 @@ public class NewPlayoutTests : PlayoutBuilderTestBase
televisionRepo, televisionRepo,
artistRepo, artistRepo,
factory, factory,
localFileSystem, new MockFileSystem(),
rerunHelper, rerunHelper,
Logger); Logger);
@ -717,7 +716,6 @@ public class NewPlayoutTests : PlayoutBuilderTestBase
IArtistRepository artistRepo = Substitute.For<IArtistRepository>(); IArtistRepository artistRepo = Substitute.For<IArtistRepository>();
IMultiEpisodeShuffleCollectionEnumeratorFactory factory = IMultiEpisodeShuffleCollectionEnumeratorFactory factory =
Substitute.For<IMultiEpisodeShuffleCollectionEnumeratorFactory>(); Substitute.For<IMultiEpisodeShuffleCollectionEnumeratorFactory>();
ILocalFileSystem localFileSystem = Substitute.For<ILocalFileSystem>();
IRerunHelper rerunHelper = Substitute.For<IRerunHelper>(); IRerunHelper rerunHelper = Substitute.For<IRerunHelper>();
var builder = new PlayoutBuilder( var builder = new PlayoutBuilder(
configRepo, configRepo,
@ -725,7 +723,7 @@ public class NewPlayoutTests : PlayoutBuilderTestBase
televisionRepo, televisionRepo,
artistRepo, artistRepo,
factory, factory,
localFileSystem, new MockFileSystem(),
rerunHelper, rerunHelper,
Logger); Logger);
@ -888,7 +886,6 @@ public class NewPlayoutTests : PlayoutBuilderTestBase
IArtistRepository artistRepo = Substitute.For<IArtistRepository>(); IArtistRepository artistRepo = Substitute.For<IArtistRepository>();
IMultiEpisodeShuffleCollectionEnumeratorFactory factory = IMultiEpisodeShuffleCollectionEnumeratorFactory factory =
Substitute.For<IMultiEpisodeShuffleCollectionEnumeratorFactory>(); Substitute.For<IMultiEpisodeShuffleCollectionEnumeratorFactory>();
ILocalFileSystem localFileSystem = Substitute.For<ILocalFileSystem>();
IRerunHelper rerunHelper = Substitute.For<IRerunHelper>(); IRerunHelper rerunHelper = Substitute.For<IRerunHelper>();
var builder = new PlayoutBuilder( var builder = new PlayoutBuilder(
configRepo, configRepo,
@ -896,7 +893,7 @@ public class NewPlayoutTests : PlayoutBuilderTestBase
televisionRepo, televisionRepo,
artistRepo, artistRepo,
factory, factory,
localFileSystem, new MockFileSystem(),
rerunHelper, rerunHelper,
Logger); Logger);
@ -1018,7 +1015,6 @@ public class NewPlayoutTests : PlayoutBuilderTestBase
IArtistRepository artistRepo = Substitute.For<IArtistRepository>(); IArtistRepository artistRepo = Substitute.For<IArtistRepository>();
IMultiEpisodeShuffleCollectionEnumeratorFactory factory = IMultiEpisodeShuffleCollectionEnumeratorFactory factory =
Substitute.For<IMultiEpisodeShuffleCollectionEnumeratorFactory>(); Substitute.For<IMultiEpisodeShuffleCollectionEnumeratorFactory>();
ILocalFileSystem localFileSystem = Substitute.For<ILocalFileSystem>();
IRerunHelper rerunHelper = Substitute.For<IRerunHelper>(); IRerunHelper rerunHelper = Substitute.For<IRerunHelper>();
var builder = new PlayoutBuilder( var builder = new PlayoutBuilder(
configRepo, configRepo,
@ -1026,7 +1022,7 @@ public class NewPlayoutTests : PlayoutBuilderTestBase
televisionRepo, televisionRepo,
artistRepo, artistRepo,
factory, factory,
localFileSystem, new MockFileSystem(),
rerunHelper, rerunHelper,
Logger); Logger);
@ -1147,7 +1143,6 @@ public class NewPlayoutTests : PlayoutBuilderTestBase
IArtistRepository artistRepo = Substitute.For<IArtistRepository>(); IArtistRepository artistRepo = Substitute.For<IArtistRepository>();
IMultiEpisodeShuffleCollectionEnumeratorFactory factory = IMultiEpisodeShuffleCollectionEnumeratorFactory factory =
Substitute.For<IMultiEpisodeShuffleCollectionEnumeratorFactory>(); Substitute.For<IMultiEpisodeShuffleCollectionEnumeratorFactory>();
ILocalFileSystem localFileSystem = Substitute.For<ILocalFileSystem>();
IRerunHelper rerunHelper = Substitute.For<IRerunHelper>(); IRerunHelper rerunHelper = Substitute.For<IRerunHelper>();
var builder = new PlayoutBuilder( var builder = new PlayoutBuilder(
configRepo, configRepo,
@ -1155,7 +1150,7 @@ public class NewPlayoutTests : PlayoutBuilderTestBase
televisionRepo, televisionRepo,
artistRepo, artistRepo,
factory, factory,
localFileSystem, new MockFileSystem(),
rerunHelper, rerunHelper,
Logger); Logger);
@ -1277,7 +1272,6 @@ public class NewPlayoutTests : PlayoutBuilderTestBase
IArtistRepository artistRepo = Substitute.For<IArtistRepository>(); IArtistRepository artistRepo = Substitute.For<IArtistRepository>();
IMultiEpisodeShuffleCollectionEnumeratorFactory factory = IMultiEpisodeShuffleCollectionEnumeratorFactory factory =
Substitute.For<IMultiEpisodeShuffleCollectionEnumeratorFactory>(); Substitute.For<IMultiEpisodeShuffleCollectionEnumeratorFactory>();
ILocalFileSystem localFileSystem = Substitute.For<ILocalFileSystem>();
IRerunHelper rerunHelper = Substitute.For<IRerunHelper>(); IRerunHelper rerunHelper = Substitute.For<IRerunHelper>();
var builder = new PlayoutBuilder( var builder = new PlayoutBuilder(
configRepo, configRepo,
@ -1285,7 +1279,7 @@ public class NewPlayoutTests : PlayoutBuilderTestBase
televisionRepo, televisionRepo,
artistRepo, artistRepo,
factory, factory,
localFileSystem, new MockFileSystem(),
rerunHelper, rerunHelper,
Logger); Logger);
@ -1412,7 +1406,6 @@ public class NewPlayoutTests : PlayoutBuilderTestBase
IArtistRepository artistRepo = Substitute.For<IArtistRepository>(); IArtistRepository artistRepo = Substitute.For<IArtistRepository>();
IMultiEpisodeShuffleCollectionEnumeratorFactory factory = IMultiEpisodeShuffleCollectionEnumeratorFactory factory =
Substitute.For<IMultiEpisodeShuffleCollectionEnumeratorFactory>(); Substitute.For<IMultiEpisodeShuffleCollectionEnumeratorFactory>();
ILocalFileSystem localFileSystem = Substitute.For<ILocalFileSystem>();
IRerunHelper rerunHelper = Substitute.For<IRerunHelper>(); IRerunHelper rerunHelper = Substitute.For<IRerunHelper>();
var builder = new PlayoutBuilder( var builder = new PlayoutBuilder(
configRepo, configRepo,
@ -1420,7 +1413,7 @@ public class NewPlayoutTests : PlayoutBuilderTestBase
televisionRepo, televisionRepo,
artistRepo, artistRepo,
factory, factory,
localFileSystem, new MockFileSystem(),
rerunHelper, rerunHelper,
Logger); Logger);
@ -1547,7 +1540,6 @@ public class NewPlayoutTests : PlayoutBuilderTestBase
IArtistRepository artistRepo = Substitute.For<IArtistRepository>(); IArtistRepository artistRepo = Substitute.For<IArtistRepository>();
IMultiEpisodeShuffleCollectionEnumeratorFactory factory = IMultiEpisodeShuffleCollectionEnumeratorFactory factory =
Substitute.For<IMultiEpisodeShuffleCollectionEnumeratorFactory>(); Substitute.For<IMultiEpisodeShuffleCollectionEnumeratorFactory>();
ILocalFileSystem localFileSystem = Substitute.For<ILocalFileSystem>();
IRerunHelper rerunHelper = Substitute.For<IRerunHelper>(); IRerunHelper rerunHelper = Substitute.For<IRerunHelper>();
var builder = new PlayoutBuilder( var builder = new PlayoutBuilder(
configRepo, configRepo,
@ -1555,7 +1547,7 @@ public class NewPlayoutTests : PlayoutBuilderTestBase
televisionRepo, televisionRepo,
artistRepo, artistRepo,
factory, factory,
localFileSystem, new MockFileSystem(),
rerunHelper, rerunHelper,
Logger); Logger);
@ -1691,7 +1683,6 @@ public class NewPlayoutTests : PlayoutBuilderTestBase
IArtistRepository artistRepo = Substitute.For<IArtistRepository>(); IArtistRepository artistRepo = Substitute.For<IArtistRepository>();
IMultiEpisodeShuffleCollectionEnumeratorFactory factory = IMultiEpisodeShuffleCollectionEnumeratorFactory factory =
Substitute.For<IMultiEpisodeShuffleCollectionEnumeratorFactory>(); Substitute.For<IMultiEpisodeShuffleCollectionEnumeratorFactory>();
ILocalFileSystem localFileSystem = Substitute.For<ILocalFileSystem>();
IRerunHelper rerunHelper = Substitute.For<IRerunHelper>(); IRerunHelper rerunHelper = Substitute.For<IRerunHelper>();
var builder = new PlayoutBuilder( var builder = new PlayoutBuilder(
configRepo, configRepo,
@ -1699,7 +1690,7 @@ public class NewPlayoutTests : PlayoutBuilderTestBase
televisionRepo, televisionRepo,
artistRepo, artistRepo,
factory, factory,
localFileSystem, new MockFileSystem(),
rerunHelper, rerunHelper,
Logger); Logger);
@ -1828,7 +1819,6 @@ public class NewPlayoutTests : PlayoutBuilderTestBase
IArtistRepository artistRepo = Substitute.For<IArtistRepository>(); IArtistRepository artistRepo = Substitute.For<IArtistRepository>();
IMultiEpisodeShuffleCollectionEnumeratorFactory factory = IMultiEpisodeShuffleCollectionEnumeratorFactory factory =
Substitute.For<IMultiEpisodeShuffleCollectionEnumeratorFactory>(); Substitute.For<IMultiEpisodeShuffleCollectionEnumeratorFactory>();
ILocalFileSystem localFileSystem = Substitute.For<ILocalFileSystem>();
IRerunHelper rerunHelper = Substitute.For<IRerunHelper>(); IRerunHelper rerunHelper = Substitute.For<IRerunHelper>();
var builder = new PlayoutBuilder( var builder = new PlayoutBuilder(
configRepo, configRepo,
@ -1836,7 +1826,7 @@ public class NewPlayoutTests : PlayoutBuilderTestBase
televisionRepo, televisionRepo,
artistRepo, artistRepo,
factory, factory,
localFileSystem, new MockFileSystem(),
rerunHelper, rerunHelper,
Logger); Logger);
@ -1931,7 +1921,6 @@ public class NewPlayoutTests : PlayoutBuilderTestBase
IArtistRepository artistRepo = Substitute.For<IArtistRepository>(); IArtistRepository artistRepo = Substitute.For<IArtistRepository>();
IMultiEpisodeShuffleCollectionEnumeratorFactory factory = IMultiEpisodeShuffleCollectionEnumeratorFactory factory =
Substitute.For<IMultiEpisodeShuffleCollectionEnumeratorFactory>(); Substitute.For<IMultiEpisodeShuffleCollectionEnumeratorFactory>();
ILocalFileSystem localFileSystem = Substitute.For<ILocalFileSystem>();
IRerunHelper rerunHelper = Substitute.For<IRerunHelper>(); IRerunHelper rerunHelper = Substitute.For<IRerunHelper>();
var builder = new PlayoutBuilder( var builder = new PlayoutBuilder(
configRepo, configRepo,
@ -1939,7 +1928,7 @@ public class NewPlayoutTests : PlayoutBuilderTestBase
televisionRepo, televisionRepo,
artistRepo, artistRepo,
factory, factory,
localFileSystem, new MockFileSystem(),
rerunHelper, rerunHelper,
Logger); Logger);

8
ErsatzTV.Core.Tests/Scheduling/ClassicScheduling/PlayoutBuilderTestBase.cs

@ -2,7 +2,6 @@ using Destructurama;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Filler; using ErsatzTV.Core.Domain.Filler;
using ErsatzTV.Core.Domain.Scheduling; using ErsatzTV.Core.Domain.Scheduling;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Scheduling; using ErsatzTV.Core.Interfaces.Scheduling;
using ErsatzTV.Core.Scheduling; using ErsatzTV.Core.Scheduling;
@ -11,6 +10,7 @@ using Microsoft.Extensions.Logging;
using NSubstitute; using NSubstitute;
using NUnit.Framework; using NUnit.Framework;
using Serilog; using Serilog;
using Testably.Abstractions.Testing;
namespace ErsatzTV.Core.Tests.Scheduling.ClassicScheduling; namespace ErsatzTV.Core.Tests.Scheduling.ClassicScheduling;
@ -66,7 +66,6 @@ public abstract class PlayoutBuilderTestBase
IArtistRepository artistRepo = Substitute.For<IArtistRepository>(); IArtistRepository artistRepo = Substitute.For<IArtistRepository>();
IMultiEpisodeShuffleCollectionEnumeratorFactory factory = IMultiEpisodeShuffleCollectionEnumeratorFactory factory =
Substitute.For<IMultiEpisodeShuffleCollectionEnumeratorFactory>(); Substitute.For<IMultiEpisodeShuffleCollectionEnumeratorFactory>();
ILocalFileSystem localFileSystem = Substitute.For<ILocalFileSystem>();
IRerunHelper rerunHelper = Substitute.For<IRerunHelper>(); IRerunHelper rerunHelper = Substitute.For<IRerunHelper>();
var builder = new PlayoutBuilder( var builder = new PlayoutBuilder(
configRepo, configRepo,
@ -74,7 +73,7 @@ public abstract class PlayoutBuilderTestBase
televisionRepo, televisionRepo,
artistRepo, artistRepo,
factory, factory,
localFileSystem, new MockFileSystem(),
rerunHelper, rerunHelper,
Logger); Logger);
@ -182,7 +181,6 @@ public abstract class PlayoutBuilderTestBase
IArtistRepository artistRepo = Substitute.For<IArtistRepository>(); IArtistRepository artistRepo = Substitute.For<IArtistRepository>();
IMultiEpisodeShuffleCollectionEnumeratorFactory factory = IMultiEpisodeShuffleCollectionEnumeratorFactory factory =
Substitute.For<IMultiEpisodeShuffleCollectionEnumeratorFactory>(); Substitute.For<IMultiEpisodeShuffleCollectionEnumeratorFactory>();
ILocalFileSystem localFileSystem = Substitute.For<ILocalFileSystem>();
IRerunHelper rerunHelper = Substitute.For<IRerunHelper>(); IRerunHelper rerunHelper = Substitute.For<IRerunHelper>();
var builder = new PlayoutBuilder( var builder = new PlayoutBuilder(
configRepo, configRepo,
@ -190,7 +188,7 @@ public abstract class PlayoutBuilderTestBase
televisionRepo, televisionRepo,
artistRepo, artistRepo,
factory, factory,
localFileSystem, new MockFileSystem(),
rerunHelper, rerunHelper,
Logger); Logger);

5
ErsatzTV.Core.Tests/Scheduling/ClassicScheduling/RefreshPlayoutTests.cs

@ -1,6 +1,5 @@
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Scheduling; using ErsatzTV.Core.Domain.Scheduling;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Scheduling; using ErsatzTV.Core.Interfaces.Scheduling;
using ErsatzTV.Core.Scheduling; using ErsatzTV.Core.Scheduling;
@ -8,6 +7,7 @@ using ErsatzTV.Core.Tests.Fakes;
using NSubstitute; using NSubstitute;
using NUnit.Framework; using NUnit.Framework;
using Shouldly; using Shouldly;
using Testably.Abstractions.Testing;
namespace ErsatzTV.Core.Tests.Scheduling.ClassicScheduling; namespace ErsatzTV.Core.Tests.Scheduling.ClassicScheduling;
@ -101,7 +101,6 @@ public class RefreshPlayoutTests : PlayoutBuilderTestBase
IArtistRepository artistRepo = Substitute.For<IArtistRepository>(); IArtistRepository artistRepo = Substitute.For<IArtistRepository>();
IMultiEpisodeShuffleCollectionEnumeratorFactory factory = IMultiEpisodeShuffleCollectionEnumeratorFactory factory =
Substitute.For<IMultiEpisodeShuffleCollectionEnumeratorFactory>(); Substitute.For<IMultiEpisodeShuffleCollectionEnumeratorFactory>();
ILocalFileSystem localFileSystem = Substitute.For<ILocalFileSystem>();
IRerunHelper rerunHelper = Substitute.For<IRerunHelper>(); IRerunHelper rerunHelper = Substitute.For<IRerunHelper>();
var builder = new PlayoutBuilder( var builder = new PlayoutBuilder(
configRepo, configRepo,
@ -109,7 +108,7 @@ public class RefreshPlayoutTests : PlayoutBuilderTestBase
televisionRepo, televisionRepo,
artistRepo, artistRepo,
factory, factory,
localFileSystem, new MockFileSystem(),
rerunHelper, rerunHelper,
Logger); Logger);

6
ErsatzTV.Core.Tests/Scheduling/ScheduleIntegrationTests.cs

@ -24,6 +24,7 @@ using NUnit.Framework;
using Serilog; using Serilog;
using Serilog.Events; using Serilog.Events;
using Serilog.Extensions.Logging; using Serilog.Extensions.Logging;
using MockFileSystem = Testably.Abstractions.Testing.MockFileSystem;
namespace ErsatzTV.Core.Tests.Scheduling; namespace ErsatzTV.Core.Tests.Scheduling;
@ -108,6 +109,7 @@ public class ScheduleIntegrationTests
ISearchIndex searchIndex = provider.GetRequiredService<ISearchIndex>(); ISearchIndex searchIndex = provider.GetRequiredService<ISearchIndex>();
await searchIndex.Initialize( await searchIndex.Initialize(
new LocalFileSystem( new LocalFileSystem(
new MockFileSystem(),
provider.GetRequiredService<IClient>(), provider.GetRequiredService<IClient>(),
provider.GetRequiredService<ILogger<LocalFileSystem>>()), provider.GetRequiredService<ILogger<LocalFileSystem>>()),
provider.GetRequiredService<IConfigElementRepository>(), provider.GetRequiredService<IConfigElementRepository>(),
@ -125,7 +127,7 @@ public class ScheduleIntegrationTests
new TelevisionRepository(factory, provider.GetRequiredService<ILogger<TelevisionRepository>>()), new TelevisionRepository(factory, provider.GetRequiredService<ILogger<TelevisionRepository>>()),
new ArtistRepository(factory), new ArtistRepository(factory),
Substitute.For<IMultiEpisodeShuffleCollectionEnumeratorFactory>(), Substitute.For<IMultiEpisodeShuffleCollectionEnumeratorFactory>(),
Substitute.For<ILocalFileSystem>(), new MockFileSystem(),
Substitute.For<IRerunHelper>(), Substitute.For<IRerunHelper>(),
provider.GetRequiredService<ILogger<PlayoutBuilder>>()); provider.GetRequiredService<ILogger<PlayoutBuilder>>());
@ -321,7 +323,7 @@ public class ScheduleIntegrationTests
new TelevisionRepository(factory, provider.GetRequiredService<ILogger<TelevisionRepository>>()), new TelevisionRepository(factory, provider.GetRequiredService<ILogger<TelevisionRepository>>()),
new ArtistRepository(factory), new ArtistRepository(factory),
Substitute.For<IMultiEpisodeShuffleCollectionEnumeratorFactory>(), Substitute.For<IMultiEpisodeShuffleCollectionEnumeratorFactory>(),
Substitute.For<ILocalFileSystem>(), new MockFileSystem(),
Substitute.For<IRerunHelper>(), Substitute.For<IRerunHelper>(),
provider.GetRequiredService<ILogger<PlayoutBuilder>>()); provider.GetRequiredService<ILogger<PlayoutBuilder>>());

1
ErsatzTV.Core/ErsatzTV.Core.csproj

@ -32,6 +32,7 @@
<PackageReference Include="SkiaSharp" Version="3.119.1" /> <PackageReference Include="SkiaSharp" Version="3.119.1" />
<PackageReference Include="SkiaSharp.NativeAssets.Linux.NoDependencies" Version="3.119.1" /> <PackageReference Include="SkiaSharp.NativeAssets.Linux.NoDependencies" Version="3.119.1" />
<PackageReference Include="System.CommandLine" Version="2.0.0-rc.2.25502.107" /> <PackageReference Include="System.CommandLine" Version="2.0.0-rc.2.25502.107" />
<PackageReference Include="Testably.Abstractions" Version="10.0.0" />
<PackageReference Include="TimeSpanParserUtil" Version="1.2.0" /> <PackageReference Include="TimeSpanParserUtil" Version="1.2.0" />
<PackageReference Include="YamlDotNet" Version="16.3.0" /> <PackageReference Include="YamlDotNet" Version="16.3.0" />
</ItemGroup> </ItemGroup>

8
ErsatzTV.Core/FFmpeg/CustomStreamSelector.cs

@ -1,8 +1,8 @@
using System.IO.Abstractions;
using System.IO.Enumeration; using System.IO.Enumeration;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.FFmpeg.Selector; using ErsatzTV.Core.FFmpeg.Selector;
using ErsatzTV.Core.Interfaces.FFmpeg; using ErsatzTV.Core.Interfaces.FFmpeg;
using ErsatzTV.Core.Interfaces.Metadata;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using NCalc; using NCalc;
using YamlDotNet.Serialization; using YamlDotNet.Serialization;
@ -10,7 +10,7 @@ using YamlDotNet.Serialization.NamingConventions;
namespace ErsatzTV.Core.FFmpeg; namespace ErsatzTV.Core.FFmpeg;
public class CustomStreamSelector(ILocalFileSystem localFileSystem, ILogger<CustomStreamSelector> logger) public class CustomStreamSelector(IFileSystem fileSystem, ILogger<CustomStreamSelector> logger)
: ICustomStreamSelector : ICustomStreamSelector
{ {
public async Task<StreamSelectorResult> SelectStreams( public async Task<StreamSelectorResult> SelectStreams(
@ -25,7 +25,7 @@ public class CustomStreamSelector(ILocalFileSystem localFileSystem, ILogger<Cust
FileSystemLayout.ChannelStreamSelectorsFolder, FileSystemLayout.ChannelStreamSelectorsFolder,
channel.StreamSelector); channel.StreamSelector);
if (!localFileSystem.FileExists(streamSelectorFile)) if (!fileSystem.File.Exists(streamSelectorFile))
{ {
logger.LogWarning("YAML stream selector file {File} does not exist; aborting.", channel.StreamSelector); logger.LogWarning("YAML stream selector file {File} does not exist; aborting.", channel.StreamSelector);
return StreamSelectorResult.None; return StreamSelectorResult.None;
@ -327,7 +327,7 @@ public class CustomStreamSelector(ILocalFileSystem localFileSystem, ILogger<Cust
{ {
try try
{ {
string yaml = await localFileSystem.ReadAllText(streamSelectorFile); string yaml = await fileSystem.File.ReadAllTextAsync(streamSelectorFile);
IDeserializer deserializer = new DeserializerBuilder() IDeserializer deserializer = new DeserializerBuilder()
.WithNamingConvention(CamelCaseNamingConvention.Instance) .WithNamingConvention(CamelCaseNamingConvention.Instance)

11
ErsatzTV.Core/FFmpeg/FFmpegStreamSelector.cs

@ -2,6 +2,7 @@
using System.Diagnostics; using System.Diagnostics;
using System.Diagnostics.CodeAnalysis; using System.Diagnostics.CodeAnalysis;
using System.Globalization; using System.Globalization;
using System.IO.Abstractions;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.FFmpeg; using ErsatzTV.Core.Interfaces.FFmpeg;
using ErsatzTV.Core.Interfaces.Metadata; using ErsatzTV.Core.Interfaces.Metadata;
@ -15,7 +16,7 @@ namespace ErsatzTV.Core.FFmpeg;
public class FFmpegStreamSelector : IFFmpegStreamSelector public class FFmpegStreamSelector : IFFmpegStreamSelector
{ {
private readonly IConfigElementRepository _configElementRepository; private readonly IConfigElementRepository _configElementRepository;
private readonly ILocalFileSystem _localFileSystem; private readonly IFileSystem _fileSystem;
private readonly ILanguageCodeService _languageCodeService; private readonly ILanguageCodeService _languageCodeService;
private readonly ILogger<FFmpegStreamSelector> _logger; private readonly ILogger<FFmpegStreamSelector> _logger;
private readonly IScriptEngine _scriptEngine; private readonly IScriptEngine _scriptEngine;
@ -25,14 +26,14 @@ public class FFmpegStreamSelector : IFFmpegStreamSelector
IScriptEngine scriptEngine, IScriptEngine scriptEngine,
IStreamSelectorRepository streamSelectorRepository, IStreamSelectorRepository streamSelectorRepository,
IConfigElementRepository configElementRepository, IConfigElementRepository configElementRepository,
ILocalFileSystem localFileSystem, IFileSystem fileSystem,
ILanguageCodeService languageCodeService, ILanguageCodeService languageCodeService,
ILogger<FFmpegStreamSelector> logger) ILogger<FFmpegStreamSelector> logger)
{ {
_scriptEngine = scriptEngine; _scriptEngine = scriptEngine;
_streamSelectorRepository = streamSelectorRepository; _streamSelectorRepository = streamSelectorRepository;
_configElementRepository = configElementRepository; _configElementRepository = configElementRepository;
_localFileSystem = localFileSystem; _fileSystem = fileSystem;
_languageCodeService = languageCodeService; _languageCodeService = languageCodeService;
_logger = logger; _logger = logger;
} }
@ -318,7 +319,7 @@ public class FFmpegStreamSelector : IFFmpegStreamSelector
"js"); "js");
_logger.LogDebug("Checking for JS Script at {Path}", jsScriptPath); _logger.LogDebug("Checking for JS Script at {Path}", jsScriptPath);
if (!_localFileSystem.FileExists(jsScriptPath)) if (!_fileSystem.File.Exists(jsScriptPath))
{ {
_logger.LogDebug("Unable to locate episode audio stream selector script; falling back to built-in logic"); _logger.LogDebug("Unable to locate episode audio stream selector script; falling back to built-in logic");
return Option<MediaStream>.None; return Option<MediaStream>.None;
@ -358,7 +359,7 @@ public class FFmpegStreamSelector : IFFmpegStreamSelector
"js"); "js");
_logger.LogDebug("Checking for JS Script at {Path}", jsScriptPath); _logger.LogDebug("Checking for JS Script at {Path}", jsScriptPath);
if (!_localFileSystem.FileExists(jsScriptPath)) if (!_fileSystem.File.Exists(jsScriptPath))
{ {
_logger.LogDebug( _logger.LogDebug(
"Unable to locate movie audio stream selector script; falling back to built-in logic"); "Unable to locate movie audio stream selector script; falling back to built-in logic");

9
ErsatzTV.Core/Interfaces/Metadata/ILocalFileSystem.cs

@ -1,22 +1,15 @@
using ErsatzTV.Core.Domain; namespace ErsatzTV.Core.Interfaces.Metadata;
namespace ErsatzTV.Core.Interfaces.Metadata;
public interface ILocalFileSystem public interface ILocalFileSystem
{ {
Unit EnsureFolderExists(string folder); Unit EnsureFolderExists(string folder);
DateTime GetLastWriteTime(string path); DateTime GetLastWriteTime(string path);
bool IsLibraryPathAccessible(LibraryPath libraryPath);
IEnumerable<string> ListSubdirectories(string folder); IEnumerable<string> ListSubdirectories(string folder);
IEnumerable<string> ListFiles(string folder); IEnumerable<string> ListFiles(string folder);
IEnumerable<string> ListFiles(string folder, string searchPattern); IEnumerable<string> ListFiles(string folder, string searchPattern);
IEnumerable<string> ListFiles(string folder, params string[] searchPatterns); IEnumerable<string> ListFiles(string folder, params string[] searchPatterns);
bool FileExists(string path);
bool FolderExists(string folder);
Task<Either<BaseError, Unit>> CopyFile(string source, string destination); Task<Either<BaseError, Unit>> CopyFile(string source, string destination);
Unit EmptyFolder(string folder); Unit EmptyFolder(string folder);
Task<string> ReadAllText(string path);
Task<string[]> ReadAllLines(string path);
Task<byte[]> GetHash(string path); Task<byte[]> GetHash(string path);
string GetCustomOrDefaultFile(string folder, string file); string GetCustomOrDefaultFile(string folder, string file);
} }

59
ErsatzTV.Core/Metadata/LocalFileSystem.cs

@ -1,21 +1,21 @@
using System.Diagnostics.CodeAnalysis; using System.Diagnostics.CodeAnalysis;
using System.IO.Abstractions;
using System.Security.Cryptography; using System.Security.Cryptography;
using Bugsnag; using Bugsnag;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Metadata; using ErsatzTV.Core.Interfaces.Metadata;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
namespace ErsatzTV.Core.Metadata; namespace ErsatzTV.Core.Metadata;
public class LocalFileSystem(IClient client, ILogger<LocalFileSystem> logger) : ILocalFileSystem public class LocalFileSystem(IFileSystem fileSystem, IClient client, ILogger<LocalFileSystem> logger) : ILocalFileSystem
{ {
public Unit EnsureFolderExists(string folder) public Unit EnsureFolderExists(string folder)
{ {
try try
{ {
if (folder != null && !Directory.Exists(folder)) if (folder != null && !fileSystem.Directory.Exists(folder))
{ {
Directory.CreateDirectory(folder); fileSystem.Directory.CreateDirectory(folder);
} }
} }
catch (Exception ex) catch (Exception ex)
@ -30,7 +30,7 @@ public class LocalFileSystem(IClient client, ILogger<LocalFileSystem> logger) :
{ {
try try
{ {
return File.GetLastWriteTimeUtc(path); return fileSystem.File.GetLastWriteTimeUtc(path);
} }
catch catch
{ {
@ -38,16 +38,13 @@ public class LocalFileSystem(IClient client, ILogger<LocalFileSystem> logger) :
} }
} }
public bool IsLibraryPathAccessible(LibraryPath libraryPath) =>
Directory.Exists(libraryPath.Path);
public IEnumerable<string> ListSubdirectories(string folder) public IEnumerable<string> ListSubdirectories(string folder)
{ {
if (Directory.Exists(folder)) if (fileSystem.Directory.Exists(folder))
{ {
try try
{ {
return Directory.EnumerateDirectories(folder); return fileSystem.Directory.EnumerateDirectories(folder);
} }
catch (UnauthorizedAccessException) catch (UnauthorizedAccessException)
{ {
@ -65,11 +62,11 @@ public class LocalFileSystem(IClient client, ILogger<LocalFileSystem> logger) :
public IEnumerable<string> ListFiles(string folder) public IEnumerable<string> ListFiles(string folder)
{ {
if (Directory.Exists(folder)) if (fileSystem.Directory.Exists(folder))
{ {
try try
{ {
return Directory.EnumerateFiles(folder, "*", SearchOption.TopDirectoryOnly) return fileSystem.Directory.EnumerateFiles(folder, "*", SearchOption.TopDirectoryOnly)
.Where(path => !Path.GetFileName(path).StartsWith("._", StringComparison.OrdinalIgnoreCase)); .Where(path => !Path.GetFileName(path).StartsWith("._", StringComparison.OrdinalIgnoreCase));
} }
catch (UnauthorizedAccessException) catch (UnauthorizedAccessException)
@ -88,11 +85,11 @@ public class LocalFileSystem(IClient client, ILogger<LocalFileSystem> logger) :
public IEnumerable<string> ListFiles(string folder, string searchPattern) public IEnumerable<string> ListFiles(string folder, string searchPattern)
{ {
if (folder is not null && Directory.Exists(folder)) if (folder is not null && fileSystem.Directory.Exists(folder))
{ {
try try
{ {
return Directory.EnumerateFiles(folder, searchPattern, SearchOption.TopDirectoryOnly) return fileSystem.Directory.EnumerateFiles(folder, searchPattern, SearchOption.TopDirectoryOnly)
.Where(path => !Path.GetFileName(path).StartsWith("._", StringComparison.OrdinalIgnoreCase)); .Where(path => !Path.GetFileName(path).StartsWith("._", StringComparison.OrdinalIgnoreCase));
} }
catch (UnauthorizedAccessException) catch (UnauthorizedAccessException)
@ -111,14 +108,15 @@ public class LocalFileSystem(IClient client, ILogger<LocalFileSystem> logger) :
public IEnumerable<string> ListFiles(string folder, params string[] searchPatterns) public IEnumerable<string> ListFiles(string folder, params string[] searchPatterns)
{ {
if (folder is not null && Directory.Exists(folder)) if (folder is not null && fileSystem.Directory.Exists(folder))
{ {
try try
{ {
return searchPatterns return searchPatterns
.SelectMany(searchPattern => .SelectMany(searchPattern =>
Directory.EnumerateFiles(folder, searchPattern, SearchOption.TopDirectoryOnly) fileSystem.Directory.EnumerateFiles(folder, searchPattern, SearchOption.TopDirectoryOnly)
.Where(path => !Path.GetFileName(path).StartsWith("._", StringComparison.OrdinalIgnoreCase))) .Where(path =>
!Path.GetFileName(path).StartsWith("._", StringComparison.OrdinalIgnoreCase)))
.Distinct(); .Distinct();
} }
catch (UnauthorizedAccessException) catch (UnauthorizedAccessException)
@ -135,22 +133,18 @@ public class LocalFileSystem(IClient client, ILogger<LocalFileSystem> logger) :
return new List<string>(); return new List<string>();
} }
public bool FileExists(string path) => File.Exists(path);
public bool FolderExists(string folder) => Directory.Exists(folder);
public async Task<Either<BaseError, Unit>> CopyFile(string source, string destination) public async Task<Either<BaseError, Unit>> CopyFile(string source, string destination)
{ {
try try
{ {
string directory = Path.GetDirectoryName(destination) ?? string.Empty; string directory = Path.GetDirectoryName(destination) ?? string.Empty;
if (!Directory.Exists(directory)) if (!fileSystem.Directory.Exists(directory))
{ {
Directory.CreateDirectory(directory); fileSystem.Directory.CreateDirectory(directory);
} }
await using FileStream sourceStream = File.OpenRead(source); await using FileSystemStream sourceStream = fileSystem.File.OpenRead(source);
await using FileStream destinationStream = File.Create(destination); await using FileSystemStream destinationStream = fileSystem.File.Create(destination);
await sourceStream.CopyToAsync(destinationStream); await sourceStream.CopyToAsync(destinationStream);
return Unit.Default; return Unit.Default;
@ -166,14 +160,14 @@ public class LocalFileSystem(IClient client, ILogger<LocalFileSystem> logger) :
{ {
try try
{ {
foreach (string file in Directory.GetFiles(folder)) foreach (string file in fileSystem.Directory.GetFiles(folder))
{ {
File.Delete(file); fileSystem.File.Delete(file);
} }
foreach (string directory in Directory.GetDirectories(folder)) foreach (string directory in fileSystem.Directory.GetDirectories(folder))
{ {
Directory.Delete(directory, true); fileSystem.Directory.Delete(directory, true);
} }
} }
catch (Exception ex) catch (Exception ex)
@ -184,20 +178,17 @@ public class LocalFileSystem(IClient client, ILogger<LocalFileSystem> logger) :
return Unit.Default; return Unit.Default;
} }
public Task<string> ReadAllText(string path) => File.ReadAllTextAsync(path);
public Task<string[]> ReadAllLines(string path) => File.ReadAllLinesAsync(path);
[SuppressMessage("Security", "CA5351:Do Not Use Broken Cryptographic Algorithms")] [SuppressMessage("Security", "CA5351:Do Not Use Broken Cryptographic Algorithms")]
public async Task<byte[]> GetHash(string path) public async Task<byte[]> GetHash(string path)
{ {
using var md5 = MD5.Create(); using var md5 = MD5.Create();
await using var stream = File.OpenRead(path); await using var stream = fileSystem.File.OpenRead(path);
return await md5.ComputeHashAsync(stream); return await md5.ComputeHashAsync(stream);
} }
public string GetCustomOrDefaultFile(string folder, string file) public string GetCustomOrDefaultFile(string folder, string file)
{ {
string path = Path.Combine(folder, file); string path = Path.Combine(folder, file);
return FileExists(path) ? path : Path.Combine(folder, $"_{file}"); return fileSystem.File.Exists(path) ? path : Path.Combine(folder, $"_{file}");
} }
} }

16
ErsatzTV.Core/Scheduling/PlayoutBuilder.cs

@ -1,9 +1,9 @@
using System.Reflection; using System.IO.Abstractions;
using System.Reflection;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Filler; using ErsatzTV.Core.Domain.Filler;
using ErsatzTV.Core.Errors; using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Extensions; using ErsatzTV.Core.Extensions;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Scheduling; using ErsatzTV.Core.Interfaces.Scheduling;
using ErsatzTV.Core.Scheduling.Engine; using ErsatzTV.Core.Scheduling.Engine;
@ -20,10 +20,10 @@ public class PlayoutBuilder : IPlayoutBuilder
{ {
private readonly IArtistRepository _artistRepository; private readonly IArtistRepository _artistRepository;
private readonly IConfigElementRepository _configElementRepository; private readonly IConfigElementRepository _configElementRepository;
private readonly ILocalFileSystem _localFileSystem;
private readonly IRerunHelper _rerunHelper; private readonly IRerunHelper _rerunHelper;
private readonly IMediaCollectionRepository _mediaCollectionRepository; private readonly IMediaCollectionRepository _mediaCollectionRepository;
private readonly IMultiEpisodeShuffleCollectionEnumeratorFactory _multiEpisodeFactory; private readonly IMultiEpisodeShuffleCollectionEnumeratorFactory _multiEpisodeFactory;
private readonly IFileSystem _fileSystem;
private readonly ITelevisionRepository _televisionRepository; private readonly ITelevisionRepository _televisionRepository;
private Playlist _debugPlaylist; private Playlist _debugPlaylist;
private ILogger<PlayoutBuilder> _logger; private ILogger<PlayoutBuilder> _logger;
@ -34,7 +34,7 @@ public class PlayoutBuilder : IPlayoutBuilder
ITelevisionRepository televisionRepository, ITelevisionRepository televisionRepository,
IArtistRepository artistRepository, IArtistRepository artistRepository,
IMultiEpisodeShuffleCollectionEnumeratorFactory multiEpisodeFactory, IMultiEpisodeShuffleCollectionEnumeratorFactory multiEpisodeFactory,
ILocalFileSystem localFileSystem, IFileSystem fileSystem,
IRerunHelper rerunHelper, IRerunHelper rerunHelper,
ILogger<PlayoutBuilder> logger) ILogger<PlayoutBuilder> logger)
{ {
@ -43,7 +43,7 @@ public class PlayoutBuilder : IPlayoutBuilder
_televisionRepository = televisionRepository; _televisionRepository = televisionRepository;
_artistRepository = artistRepository; _artistRepository = artistRepository;
_multiEpisodeFactory = multiEpisodeFactory; _multiEpisodeFactory = multiEpisodeFactory;
_localFileSystem = localFileSystem; _fileSystem = fileSystem;
_rerunHelper = rerunHelper; _rerunHelper = rerunHelper;
_logger = logger; _logger = logger;
} }
@ -400,12 +400,12 @@ public class PlayoutBuilder : IPlayoutBuilder
name => name =>
{ {
_logger.LogError( _logger.LogError(
"Unable to rebuild playout; {CollectionType} {CollectionName} has no valid items!", "Unable to rebuild playout; {CollectionType} \"{CollectionName}\" has no valid items!",
emptyCollection.CollectionType, emptyCollection.CollectionType,
name); name);
return BaseError.New( return BaseError.New(
$"Unable to rebuild playout; {emptyCollection.CollectionType} {name} has no valid items!"); $"Unable to rebuild playout; {emptyCollection.CollectionType} \"{name}\" has no valid items!");
}, },
() => () =>
{ {
@ -1403,7 +1403,7 @@ public class PlayoutBuilder : IPlayoutBuilder
guid.Guid.Replace("://", "_")), guid.Guid.Replace("://", "_")),
"js"); "js");
_logger.LogDebug("Checking for JS Script at {Path}", jsScriptPath); _logger.LogDebug("Checking for JS Script at {Path}", jsScriptPath);
if (_localFileSystem.FileExists(jsScriptPath)) if (_fileSystem.File.Exists(jsScriptPath))
{ {
_logger.LogDebug("Found JS Script at {Path}", jsScriptPath); _logger.LogDebug("Found JS Script at {Path}", jsScriptPath);
try try

6
ErsatzTV.Core/Scheduling/ScriptedScheduling/ScriptedPlayoutBuilder.cs

@ -1,8 +1,8 @@
using System.CommandLine.Parsing; using System.CommandLine.Parsing;
using System.IO.Abstractions;
using CliWrap; using CliWrap;
using CliWrap.Buffered; using CliWrap.Buffered;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Scheduling; using ErsatzTV.Core.Interfaces.Scheduling;
using ErsatzTV.Core.Scheduling.Engine; using ErsatzTV.Core.Scheduling.Engine;
@ -14,7 +14,7 @@ public class ScriptedPlayoutBuilder(
IConfigElementRepository configElementRepository, IConfigElementRepository configElementRepository,
IScriptedPlayoutBuilderService scriptedPlayoutBuilderService, IScriptedPlayoutBuilderService scriptedPlayoutBuilderService,
ISchedulingEngine schedulingEngine, ISchedulingEngine schedulingEngine,
ILocalFileSystem localFileSystem, IFileSystem fileSystem,
ILogger<ScriptedPlayoutBuilder> logger) ILogger<ScriptedPlayoutBuilder> logger)
: IScriptedPlayoutBuilder : IScriptedPlayoutBuilder
{ {
@ -38,7 +38,7 @@ public class ScriptedPlayoutBuilder(
string scriptFile = args[0]; string scriptFile = args[0];
string[] scriptArgs = args.Skip(1).ToArray(); string[] scriptArgs = args.Skip(1).ToArray();
if (!localFileSystem.FileExists(scriptFile)) if (!fileSystem.File.Exists(scriptFile))
{ {
logger.LogError( logger.LogError(
"Cannot build scripted playout; schedule file {File} does not exist", "Cannot build scripted playout; schedule file {File} does not exist",

6
ErsatzTV.Core/Scheduling/YamlScheduling/SequentialPlayoutBuilder.cs

@ -1,7 +1,7 @@
using System.Collections.Immutable; using System.Collections.Immutable;
using System.IO.Abstractions;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Scheduling; using ErsatzTV.Core.Domain.Scheduling;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Scheduling; using ErsatzTV.Core.Interfaces.Scheduling;
//using ErsatzTV.Core.Scheduling.Engine; //using ErsatzTV.Core.Scheduling.Engine;
@ -17,7 +17,7 @@ namespace ErsatzTV.Core.Scheduling.YamlScheduling;
public class SequentialPlayoutBuilder( public class SequentialPlayoutBuilder(
//ISchedulingEngine schedulingEngine, //ISchedulingEngine schedulingEngine,
ILocalFileSystem localFileSystem, IFileSystem fileSystem,
IConfigElementRepository configElementRepository, IConfigElementRepository configElementRepository,
IMediaCollectionRepository mediaCollectionRepository, IMediaCollectionRepository mediaCollectionRepository,
IChannelRepository channelRepository, IChannelRepository channelRepository,
@ -38,7 +38,7 @@ public class SequentialPlayoutBuilder(
PlayoutBuildResult result = PlayoutBuildResult.Empty; PlayoutBuildResult result = PlayoutBuildResult.Empty;
if (!localFileSystem.FileExists(playout.ScheduleFile)) if (!fileSystem.File.Exists(playout.ScheduleFile))
{ {
logger.LogWarning("Sequential schedule file {File} does not exist; aborting.", playout.ScheduleFile); logger.LogWarning("Sequential schedule file {File} does not exist; aborting.", playout.ScheduleFile);
return BaseError.New($"Sequential schedule file {playout.ScheduleFile} does not exist"); return BaseError.New($"Sequential schedule file {playout.ScheduleFile} does not exist");

7
ErsatzTV.Infrastructure.Tests/ErsatzTV.Infrastructure.Tests.csproj

@ -22,10 +22,17 @@
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
</PackageReference> </PackageReference>
<PackageReference Include="Shouldly" Version="4.3.0" /> <PackageReference Include="Shouldly" Version="4.3.0" />
<PackageReference Include="Testably.Abstractions.Testing" Version="5.0.0" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\ErsatzTV.Infrastructure\ErsatzTV.Infrastructure.csproj" /> <ProjectReference Include="..\ErsatzTV.Infrastructure\ErsatzTV.Infrastructure.csproj" />
</ItemGroup> </ItemGroup>
<ItemGroup>
<EmbeddedResource Include="..\ErsatzTV\Resources\sequential-schedule.schema.json">
<Link>Resources/sequential-schedule.schema.json</Link>
</EmbeddedResource>
</ItemGroup>
</Project> </Project>

2
ErsatzTV.Infrastructure.Tests/Metadata/LocalStatisticsProviderTests.cs

@ -8,6 +8,7 @@ using Microsoft.Extensions.Logging;
using NSubstitute; using NSubstitute;
using NUnit.Framework; using NUnit.Framework;
using Shouldly; using Shouldly;
using Testably.Abstractions.Testing;
namespace ErsatzTV.Infrastructure.Tests.Metadata; namespace ErsatzTV.Infrastructure.Tests.Metadata;
@ -21,6 +22,7 @@ public class LocalStatisticsProviderTests
{ {
var provider = new LocalStatisticsProvider( var provider = new LocalStatisticsProvider(
Substitute.For<IMetadataRepository>(), Substitute.For<IMetadataRepository>(),
new MockFileSystem(),
Substitute.For<ILocalFileSystem>(), Substitute.For<ILocalFileSystem>(),
Substitute.For<IClient>(), Substitute.For<IClient>(),
Substitute.For<IHardwareCapabilitiesFactory>(), Substitute.For<IHardwareCapabilitiesFactory>(),

200
ErsatzTV.Infrastructure.Tests/Scheduling/SequentialScheduleValidatorTests.cs

@ -0,0 +1,200 @@
using System.Reflection;
using ErsatzTV.Core;
using ErsatzTV.Infrastructure.Scheduling;
using Microsoft.Extensions.Logging;
using NSubstitute;
using NUnit.Framework;
using Shouldly;
using Testably.Abstractions.Testing;
namespace ErsatzTV.Infrastructure.Tests.Scheduling;
[TestFixture]
public class SequentialScheduleValidatorTests
{
private readonly string _schema;
public SequentialScheduleValidatorTests()
{
var assembly = Assembly.GetAssembly(typeof(SequentialScheduleValidatorTests));
assembly.ShouldNotBeNull();
using var stream = assembly.GetManifestResourceStream(
"ErsatzTV.Infrastructure.Tests.Resources.sequential-schedule.schema.json");
stream.ShouldNotBeNull();
using var reader = new StreamReader(stream);
_schema = reader.ReadToEnd();
}
[CancelAfter(2_000)]
[Test]
public async Task ValidateSchedule_Should_Succeed_Valid_Schedule(CancellationToken cancellationToken)
{
const string YAML =
"""
content:
- show:
key: "SOME_SHOW"
guids:
- source: "imdb"
value: "tt123456"
order: chronological
- search:
key: "FILLER"
query: "type:other_video"
order: "shuffle"
reset:
- wait_until: '8:00am'
tomorrow: false
rewind_on_reset: true
playout:
- duration: "30 minutes"
content: "SOME_SHOW"
discard_attempts: 2
offline_tail: false
- epg_group: true
advance: false
- pad_to_next: 30
content: "FILLER"
filler_kind: postroll
trim: true
- epg_group: false
- repeat: true
""";
string schemaFileName = Path.Combine(FileSystemLayout.ResourcesCacheFolder, "sequential-schedule.schema.json");
var fileSystem = new MockFileSystem();
fileSystem.Initialize()
.WithFile(schemaFileName).Which(f => f.HasStringContent(_schema));
var validator = new SequentialScheduleValidator(
fileSystem,
Substitute.For<ILogger<SequentialScheduleValidator>>());
bool result = await validator.ValidateSchedule(YAML, false);
result.ShouldBeTrue();
}
[CancelAfter(2_000)]
[Test]
public async Task ValidateSchedule_Should_Fail_Invalid_Schedule(CancellationToken cancellationToken)
{
const string YAML =
"""
content:
- show:
key: "SOME_SHOW"
guids22:
- source: "imdb"
value: "tt123456"
order: chronological
- search:
key: "FILLER"
query: "type:other_video"
order: "shuffle"
reset:
- wait_until: '8:00am'
tomorrow: false
rewind_on_reset: true
playout:
- duration: "30 minutes"
content: "SOME_SHOW"
discard_attempts: 2
offline_tail: false
- epg_group: true
advance: false
- pad_to_next: 30
content: "FILLER"
filler_kind: postroll
trim: true
- epg_group: false
- repeat: true
""";
string schemaFileName = Path.Combine(FileSystemLayout.ResourcesCacheFolder, "sequential-schedule.schema.json");
var fileSystem = new MockFileSystem();
fileSystem.Initialize()
.WithFile(schemaFileName).Which(f => f.HasStringContent(_schema));
var validator = new SequentialScheduleValidator(
fileSystem,
Substitute.For<ILogger<SequentialScheduleValidator>>());
bool result = await validator.ValidateSchedule(YAML, false);
result.ShouldBeFalse();
}
[CancelAfter(2_000)]
[Test]
public async Task GetValidationMessages_With_Invalid_Schedule(CancellationToken cancellationToken)
{
const string YAML =
"""
content:
- show:
key: "SOME_SHOW"
guids22:
- source: "imdb"
value: "tt123456"
order: chronological
- search:
key: "FILLER"
query: "type:other_video"
order: "shuffle"
reset:
- wait_until: '8:00am'
tomorrow: false
rewind_on_reset: true
playout:
- duration: "30 minutes"
content: "SOME_SHOW"
discard_attempts: 2
offline_tail: false
- epg_group: true
advance: false
- pad_to_next: 30
content: "FILLER"
filler_kind: postroll
trim: true
- epg_group: false
- repeat: true
""";
string schemaFileName = Path.Combine(FileSystemLayout.ResourcesCacheFolder, "sequential-schedule.schema.json");
var fileSystem = new MockFileSystem();
fileSystem.Initialize()
.WithFile(schemaFileName).Which(f => f.HasStringContent(_schema));
var validator = new SequentialScheduleValidator(
fileSystem,
Substitute.For<ILogger<SequentialScheduleValidator>>());
IList<string> result = await validator.GetValidationMessages(YAML, false);
result.ShouldNotBeNull();
result.Count.ShouldBe(1);
result[0].ShouldContain("line 3");
result[0].ShouldContain("position 5");
}
}

46
ErsatzTV.Infrastructure/Data/Repositories/LibraryRepository.cs

@ -1,26 +1,18 @@
using Dapper; using System.IO.Abstractions;
using Dapper;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Infrastructure.Extensions; using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Infrastructure.Data.Repositories; namespace ErsatzTV.Infrastructure.Data.Repositories;
public class LibraryRepository : ILibraryRepository public class LibraryRepository(IFileSystem fileSystem, IDbContextFactory<TvContext> dbContextFactory)
: ILibraryRepository
{ {
private readonly IDbContextFactory<TvContext> _dbContextFactory;
private readonly ILocalFileSystem _localFileSystem;
public LibraryRepository(ILocalFileSystem localFileSystem, IDbContextFactory<TvContext> dbContextFactory)
{
_localFileSystem = localFileSystem;
_dbContextFactory = dbContextFactory;
}
public async Task<LibraryPath> Add(LibraryPath libraryPath) public async Task<LibraryPath> Add(LibraryPath libraryPath)
{ {
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(); await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync();
await dbContext.LibraryPaths.AddAsync(libraryPath); await dbContext.LibraryPaths.AddAsync(libraryPath);
await dbContext.SaveChangesAsync(); await dbContext.SaveChangesAsync();
return libraryPath; return libraryPath;
@ -28,7 +20,7 @@ public class LibraryRepository : ILibraryRepository
public async Task<Option<Library>> GetLibrary(int libraryId) public async Task<Option<Library>> GetLibrary(int libraryId)
{ {
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(); await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync();
return await dbContext.Libraries return await dbContext.Libraries
.Include(l => l.Paths) .Include(l => l.Paths)
.ThenInclude(p => p.LibraryFolders) .ThenInclude(p => p.LibraryFolders)
@ -40,7 +32,7 @@ public class LibraryRepository : ILibraryRepository
public async Task<Option<LocalLibrary>> GetLocal(int libraryId) public async Task<Option<LocalLibrary>> GetLocal(int libraryId)
{ {
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(); await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync();
return await dbContext.LocalLibraries return await dbContext.LocalLibraries
.OrderBy(l => l.Id) .OrderBy(l => l.Id)
.SingleOrDefaultAsync(l => l.Id == libraryId) .SingleOrDefaultAsync(l => l.Id == libraryId)
@ -49,7 +41,7 @@ public class LibraryRepository : ILibraryRepository
public async Task<List<Library>> GetAll() public async Task<List<Library>> GetAll()
{ {
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(); await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync();
return await dbContext.Libraries return await dbContext.Libraries
.AsNoTracking() .AsNoTracking()
.Include(l => l.MediaSource) .Include(l => l.MediaSource)
@ -59,7 +51,7 @@ public class LibraryRepository : ILibraryRepository
public async Task<Unit> UpdateLastScan(Library library) public async Task<Unit> UpdateLastScan(Library library)
{ {
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(); await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync();
return await dbContext.Connection.ExecuteAsync( return await dbContext.Connection.ExecuteAsync(
"UPDATE Library SET LastScan = @LastScan WHERE Id = @Id", "UPDATE Library SET LastScan = @LastScan WHERE Id = @Id",
new { library.LastScan, library.Id }).ToUnit(); new { library.LastScan, library.Id }).ToUnit();
@ -67,7 +59,7 @@ public class LibraryRepository : ILibraryRepository
public async Task<Unit> UpdateLastScan(LibraryPath libraryPath) public async Task<Unit> UpdateLastScan(LibraryPath libraryPath)
{ {
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(); await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync();
return await dbContext.Connection.ExecuteAsync( return await dbContext.Connection.ExecuteAsync(
"UPDATE LibraryPath SET LastScan = @LastScan WHERE Id = @Id", "UPDATE LibraryPath SET LastScan = @LastScan WHERE Id = @Id",
new { libraryPath.LastScan, libraryPath.Id }).ToUnit(); new { libraryPath.LastScan, libraryPath.Id }).ToUnit();
@ -75,7 +67,7 @@ public class LibraryRepository : ILibraryRepository
public async Task<List<LibraryPath>> GetLocalPaths(int libraryId) public async Task<List<LibraryPath>> GetLocalPaths(int libraryId)
{ {
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(); await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync();
return await dbContext.LocalLibraries return await dbContext.LocalLibraries
.Include(l => l.Paths) .Include(l => l.Paths)
.OrderBy(l => l.Id) .OrderBy(l => l.Id)
@ -86,7 +78,7 @@ public class LibraryRepository : ILibraryRepository
public async Task<int> CountMediaItemsByPath(int libraryPathId) public async Task<int> CountMediaItemsByPath(int libraryPathId)
{ {
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(); await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync();
return await dbContext.Connection.QuerySingleAsync<int>( return await dbContext.Connection.QuerySingleAsync<int>(
@"SELECT COUNT(*) FROM MediaItem WHERE LibraryPathId = @LibraryPathId", @"SELECT COUNT(*) FROM MediaItem WHERE LibraryPathId = @LibraryPathId",
new { LibraryPathId = libraryPathId }); new { LibraryPathId = libraryPathId });
@ -98,7 +90,7 @@ public class LibraryRepository : ILibraryRepository
string path, string path,
string etag) string etag)
{ {
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(); await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync();
foreach (LibraryFolder folder in knownFolder) foreach (LibraryFolder folder in knownFolder)
{ {
@ -123,10 +115,10 @@ public class LibraryRepository : ILibraryRepository
public async Task CleanEtagsForLibraryPath(LibraryPath libraryPath) public async Task CleanEtagsForLibraryPath(LibraryPath libraryPath)
{ {
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(); await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync();
IOrderedEnumerable<LibraryFolder> orderedFolders = libraryPath.LibraryFolders IOrderedEnumerable<LibraryFolder> orderedFolders = libraryPath.LibraryFolders
.Where(f => !_localFileSystem.FolderExists(f.Path)) .Where(f => !fileSystem.Directory.Exists(f.Path))
.OrderByDescending(lp => lp.Path.Length); .OrderByDescending(lp => lp.Path.Length);
foreach (LibraryFolder folder in orderedFolders) foreach (LibraryFolder folder in orderedFolders)
@ -152,7 +144,7 @@ public class LibraryRepository : ILibraryRepository
return Option<int>.None; return Option<int>.None;
} }
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
return await dbContext.LibraryFolders return await dbContext.LibraryFolders
.AsNoTracking() .AsNoTracking()
@ -166,7 +158,7 @@ public class LibraryRepository : ILibraryRepository
Option<int> maybeParentFolder, Option<int> maybeParentFolder,
string folder) string folder)
{ {
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(); await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync();
// load from db or create new folder // load from db or create new folder
LibraryFolder knownFolder = await libraryPath.LibraryFolders LibraryFolder knownFolder = await libraryPath.LibraryFolders
@ -199,7 +191,7 @@ public class LibraryRepository : ILibraryRepository
public async Task UpdateLibraryFolderId(MediaFile mediaFile, int libraryFolderId) public async Task UpdateLibraryFolderId(MediaFile mediaFile, int libraryFolderId)
{ {
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(); await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync();
mediaFile.LibraryFolderId = libraryFolderId; mediaFile.LibraryFolderId = libraryFolderId;
await dbContext.Connection.ExecuteAsync( await dbContext.Connection.ExecuteAsync(
"UPDATE MediaFile SET LibraryFolderId = @LibraryFolderId WHERE Id = @Id", "UPDATE MediaFile SET LibraryFolderId = @LibraryFolderId WHERE Id = @Id",
@ -208,7 +200,7 @@ public class LibraryRepository : ILibraryRepository
public async Task UpdatePath(LibraryPath libraryPath, string normalizedLibraryPath) public async Task UpdatePath(LibraryPath libraryPath, string normalizedLibraryPath)
{ {
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(); await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync();
libraryPath.Path = normalizedLibraryPath; libraryPath.Path = normalizedLibraryPath;
await dbContext.Connection.ExecuteAsync( await dbContext.Connection.ExecuteAsync(
"UPDATE LibraryPath SET Path = @Path WHERE Id = @Id", "UPDATE LibraryPath SET Path = @Path WHERE Id = @Id",

5
ErsatzTV.Infrastructure/Data/Repositories/MediaCollectionRepository.cs

@ -1087,7 +1087,10 @@ public class MediaCollectionRepository : IMediaCollectionRepository
.AsNoTracking() .AsNoTracking()
.SelectOneAsync(a => a.Id, a => a.Id == emptyCollection.MediaItemId.Value, cancellationToken) .SelectOneAsync(a => a.Id, a => a.Id == emptyCollection.MediaItemId.Value, cancellationToken)
.MapT(s => s.ShowMetadata.Head().Title), .MapT(s => s.ShowMetadata.Head().Title),
// TODO: get playlist name CollectionType.Playlist => await dbContext.Playlists
.AsNoTracking()
.SelectOneAsync(p => p.Id, p => p.Id == emptyCollection.PlaylistId.Value, cancellationToken)
.MapT(p => p.Name),
_ => None _ => None
}; };
} }

8
ErsatzTV.Infrastructure/Data/Repositories/TemplateDataRepository.cs

@ -1,8 +1,8 @@
using System.Globalization; using System.Globalization;
using System.IO.Abstractions;
using ErsatzTV.Core; using ErsatzTV.Core;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Extensions; using ErsatzTV.Core.Extensions;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Metadata; using ErsatzTV.Core.Metadata;
using ErsatzTV.Infrastructure.Epg; using ErsatzTV.Infrastructure.Epg;
@ -12,7 +12,7 @@ using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Infrastructure.Data.Repositories; namespace ErsatzTV.Infrastructure.Data.Repositories;
public class TemplateDataRepository(ILocalFileSystem localFileSystem, IDbContextFactory<TvContext> dbContextFactory) public class TemplateDataRepository(IFileSystem fileSystem, IDbContextFactory<TvContext> dbContextFactory)
: ITemplateDataRepository : ITemplateDataRepository
{ {
public async Task<Option<Dictionary<string, object>>> GetMediaItemTemplateData( public async Task<Option<Dictionary<string, object>>> GetMediaItemTemplateData(
@ -65,9 +65,9 @@ public class TemplateDataRepository(ILocalFileSystem localFileSystem, IDbContext
} }
string targetFile = Path.Combine(FileSystemLayout.ChannelGuideCacheFolder, $"{channelNumber}.xml"); string targetFile = Path.Combine(FileSystemLayout.ChannelGuideCacheFolder, $"{channelNumber}.xml");
if (localFileSystem.FileExists(targetFile)) if (fileSystem.File.Exists(targetFile))
{ {
await using FileStream stream = File.OpenRead(targetFile); await using FileSystemStream stream = fileSystem.File.OpenRead(targetFile);
List<EpgProgramme> xmlProgrammes = EpgReader.FindProgrammesAt(stream, time, count); List<EpgProgramme> xmlProgrammes = EpgReader.FindProgrammesAt(stream, time, count);
var result = new List<Dictionary<string, object>>(); var result = new List<Dictionary<string, object>>();

1
ErsatzTV.Infrastructure/ErsatzTV.Infrastructure.csproj

@ -16,6 +16,7 @@
<PackageReference Include="EFCore.BulkExtensions" Version="9.0.2" /> <PackageReference Include="EFCore.BulkExtensions" Version="9.0.2" />
<PackageReference Include="Elastic.Clients.Elasticsearch" Version="9.2.1" /> <PackageReference Include="Elastic.Clients.Elasticsearch" Version="9.2.1" />
<PackageReference Include="Jint" Version="4.4.2" /> <PackageReference Include="Jint" Version="4.4.2" />
<PackageReference Include="JsonSchema.Net" Version="7.4.0" />
<PackageReference Include="Lucene.Net" Version="4.8.0-beta00017" /> <PackageReference Include="Lucene.Net" Version="4.8.0-beta00017" />
<PackageReference Include="Lucene.Net.Analysis.Common" Version="4.8.0-beta00017" /> <PackageReference Include="Lucene.Net.Analysis.Common" Version="4.8.0-beta00017" />
<PackageReference Include="Lucene.Net.QueryParser" Version="4.8.0-beta00017" /> <PackageReference Include="Lucene.Net.QueryParser" Version="4.8.0-beta00017" />

8
ErsatzTV.Infrastructure/FFmpeg/MpegTsScriptService.cs

@ -1,4 +1,5 @@
using System.Collections.Concurrent; using System.Collections.Concurrent;
using System.IO.Abstractions;
using System.Runtime.InteropServices; using System.Runtime.InteropServices;
using CliWrap; using CliWrap;
using ErsatzTV.Core; using ErsatzTV.Core;
@ -15,6 +16,7 @@ using YamlDotNet.Serialization.NamingConventions;
namespace ErsatzTV.Infrastructure.FFmpeg; namespace ErsatzTV.Infrastructure.FFmpeg;
public class MpegTsScriptService( public class MpegTsScriptService(
IFileSystem fileSystem,
ILocalFileSystem localFileSystem, ILocalFileSystem localFileSystem,
ITempFilePool tempFilePool, ITempFilePool tempFilePool,
ILogger<MpegTsScriptService> logger) : IMpegTsScriptService ILogger<MpegTsScriptService> logger) : IMpegTsScriptService
@ -26,9 +28,9 @@ public class MpegTsScriptService(
foreach (string folder in localFileSystem.ListSubdirectories(FileSystemLayout.MpegTsScriptsFolder)) foreach (string folder in localFileSystem.ListSubdirectories(FileSystemLayout.MpegTsScriptsFolder))
{ {
string definition = Path.Combine(folder, "mpegts.yml"); string definition = Path.Combine(folder, "mpegts.yml");
if (!Scripts.ContainsKey(folder) && localFileSystem.FileExists(definition)) if (!Scripts.ContainsKey(folder) && fileSystem.File.Exists(definition))
{ {
Option<MpegTsScript> maybeScript = FromYaml(await localFileSystem.ReadAllText(definition)); Option<MpegTsScript> maybeScript = FromYaml(await fileSystem.File.ReadAllTextAsync(definition));
foreach (var script in maybeScript) foreach (var script in maybeScript)
{ {
script.Id = Path.GetFileName(folder); script.Id = Path.GetFileName(folder);
@ -94,7 +96,7 @@ public class MpegTsScriptService(
string channelName, string channelName,
string ffmpegPath) string ffmpegPath)
{ {
string script = await localFileSystem.ReadAllText(fileName); string script = await fileSystem.File.ReadAllTextAsync(fileName);
try try
{ {
var data = new Dictionary<string, string> var data = new Dictionary<string, string>

30
ErsatzTV.Infrastructure/Images/ImageCache.cs

@ -1,5 +1,6 @@
using System.Diagnostics.CodeAnalysis; using System.Diagnostics.CodeAnalysis;
using System.Globalization; using System.Globalization;
using System.IO.Abstractions;
using System.Security.Cryptography; using System.Security.Cryptography;
using System.Text; using System.Text;
using Blurhash.SkiaSharp; using Blurhash.SkiaSharp;
@ -14,27 +15,18 @@ using SkiaSharp;
namespace ErsatzTV.Infrastructure.Images; namespace ErsatzTV.Infrastructure.Images;
[SuppressMessage("Security", "CA5350:Do Not Use Weak Cryptographic Algorithms")] [SuppressMessage("Security", "CA5350:Do Not Use Weak Cryptographic Algorithms")]
public class ImageCache : IImageCache public class ImageCache(IFileSystem fileSystem, ILocalFileSystem localFileSystem, ITempFilePool tempFilePool)
: IImageCache
{ {
private static readonly SHA1 Crypto; private static readonly SHA1 Crypto;
private readonly ILocalFileSystem _localFileSystem;
private readonly ITempFilePool _tempFilePool;
static ImageCache() => Crypto = SHA1.Create(); static ImageCache() => Crypto = SHA1.Create();
public ImageCache(
ILocalFileSystem localFileSystem,
ITempFilePool tempFilePool)
{
_localFileSystem = localFileSystem;
_tempFilePool = tempFilePool;
}
public async Task<Either<BaseError, string>> SaveArtworkToCache(Stream stream, ArtworkKind artworkKind) public async Task<Either<BaseError, string>> SaveArtworkToCache(Stream stream, ArtworkKind artworkKind)
{ {
try try
{ {
string tempFileName = _tempFilePool.GetNextTempFile(TempFileCategory.CachedArtwork); string tempFileName = tempFilePool.GetNextTempFile(TempFileCategory.CachedArtwork);
// ReSharper disable once UseAwaitUsing // ReSharper disable once UseAwaitUsing
using (var fs = new FileStream(tempFileName, FileMode.OpenOrCreate, FileAccess.Write)) using (var fs = new FileStream(tempFileName, FileMode.OpenOrCreate, FileAccess.Write))
{ {
@ -63,7 +55,7 @@ public class ImageCache : IImageCache
Directory.CreateDirectory(baseFolder); Directory.CreateDirectory(baseFolder);
} }
await _localFileSystem.CopyFile(tempFileName, target); await localFileSystem.CopyFile(tempFileName, target);
return hex; return hex;
} }
@ -77,7 +69,7 @@ public class ImageCache : IImageCache
{ {
try try
{ {
var filenameKey = $"{path}:{_localFileSystem.GetLastWriteTime(path).ToFileTimeUtc()}"; var filenameKey = $"{path}:{localFileSystem.GetLastWriteTime(path).ToFileTimeUtc()}";
byte[] hash = Crypto.ComputeHash(Encoding.UTF8.GetBytes(filenameKey)); byte[] hash = Crypto.ComputeHash(Encoding.UTF8.GetBytes(filenameKey));
string hex = Convert.ToHexString(hash); string hex = Convert.ToHexString(hash);
string subfolder = hex[..2]; string subfolder = hex[..2];
@ -90,7 +82,7 @@ public class ImageCache : IImageCache
_ => FileSystemLayout.LegacyImageCacheFolder _ => FileSystemLayout.LegacyImageCacheFolder
}; };
string target = Path.Combine(baseFolder, hex); string target = Path.Combine(baseFolder, hex);
Either<BaseError, Unit> maybeResult = await _localFileSystem.CopyFile(path, target); Either<BaseError, Unit> maybeResult = await localFileSystem.CopyFile(path, target);
return maybeResult.Match<Either<BaseError, string>>( return maybeResult.Match<Either<BaseError, string>>(
_ => hex, _ => hex,
error => error); error => error);
@ -159,14 +151,14 @@ public class ImageCache : IImageCache
{ {
byte[] bytes = Encoding.UTF8.GetBytes(blurHash); byte[] bytes = Encoding.UTF8.GetBytes(blurHash);
string base64 = Convert.ToBase64String(bytes).Replace("+", "_").Replace("/", "-").Replace("=", ""); string base64 = Convert.ToBase64String(bytes).Replace("+", "_").Replace("/", "-").Replace("=", "");
string targetFile = GetPathForImage(base64, ArtworkKind.Poster, targetSize.Height); string targetFile = GetPathForImage(base64, ArtworkKind.Poster, targetSize.Height) ?? string.Empty;
if (!_localFileSystem.FileExists(targetFile)) if (!fileSystem.File.Exists(targetFile))
{ {
string folder = Path.GetDirectoryName(targetFile); string folder = Path.GetDirectoryName(targetFile);
_localFileSystem.EnsureFolderExists(folder); localFileSystem.EnsureFolderExists(folder);
// ReSharper disable once ConvertToUsingDeclaration // ReSharper disable once ConvertToUsingDeclaration
using (FileStream fs = File.OpenWrite(targetFile)) using (FileSystemStream fs = fileSystem.File.OpenWrite(targetFile))
{ {
using (SKBitmap image = Blurhasher.Decode(blurHash, targetSize.Width, targetSize.Height)) using (SKBitmap image = Blurhasher.Decode(blurHash, targetSize.Width, targetSize.Height))
{ {

6
ErsatzTV.Infrastructure/Metadata/LocalStatisticsProvider.cs

@ -1,6 +1,7 @@
using System.Diagnostics; using System.Diagnostics;
using System.Diagnostics.CodeAnalysis; using System.Diagnostics.CodeAnalysis;
using System.Globalization; using System.Globalization;
using System.IO.Abstractions;
using System.Text; using System.Text;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using Bugsnag; using Bugsnag;
@ -25,15 +26,18 @@ public partial class LocalStatisticsProvider : ILocalStatisticsProvider
private readonly ILocalFileSystem _localFileSystem; private readonly ILocalFileSystem _localFileSystem;
private readonly ILogger<LocalStatisticsProvider> _logger; private readonly ILogger<LocalStatisticsProvider> _logger;
private readonly IMetadataRepository _metadataRepository; private readonly IMetadataRepository _metadataRepository;
private readonly IFileSystem _fileSystem;
public LocalStatisticsProvider( public LocalStatisticsProvider(
IMetadataRepository metadataRepository, IMetadataRepository metadataRepository,
IFileSystem fileSystem,
ILocalFileSystem localFileSystem, ILocalFileSystem localFileSystem,
IClient client, IClient client,
IHardwareCapabilitiesFactory hardwareCapabilitiesFactory, IHardwareCapabilitiesFactory hardwareCapabilitiesFactory,
ILogger<LocalStatisticsProvider> logger) ILogger<LocalStatisticsProvider> logger)
{ {
_metadataRepository = metadataRepository; _metadataRepository = metadataRepository;
_fileSystem = fileSystem;
_localFileSystem = localFileSystem; _localFileSystem = localFileSystem;
_client = client; _client = client;
_hardwareCapabilitiesFactory = hardwareCapabilitiesFactory; _hardwareCapabilitiesFactory = hardwareCapabilitiesFactory;
@ -146,7 +150,7 @@ public partial class LocalStatisticsProvider : ILocalStatisticsProvider
} }
if (filePath.StartsWith("http", StringComparison.OrdinalIgnoreCase) || if (filePath.StartsWith("http", StringComparison.OrdinalIgnoreCase) ||
!_localFileSystem.FileExists(filePath)) !_fileSystem.File.Exists(filePath))
{ {
_logger.LogDebug("Skipping interlaced ratio check for remote content"); _logger.LogDebug("Skipping interlaced ratio check for remote content");
return Option<double>.None; return Option<double>.None;

62
ErsatzTV.Infrastructure/Scheduling/SequentialScheduleValidator.cs

@ -1,5 +1,7 @@
using System.Globalization; using System.Globalization;
using System.IO.Abstractions;
using System.Text.Json; using System.Text.Json;
using System.Text.Json.Nodes;
using ErsatzTV.Core; using ErsatzTV.Core;
using ErsatzTV.Core.Interfaces.Scheduling; using ErsatzTV.Core.Interfaces.Scheduling;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
@ -8,30 +10,30 @@ using Newtonsoft.Json.Linq;
using Newtonsoft.Json.Schema; using Newtonsoft.Json.Schema;
using YamlDotNet.RepresentationModel; using YamlDotNet.RepresentationModel;
using JsonSerializer = System.Text.Json.JsonSerializer; using JsonSerializer = System.Text.Json.JsonSerializer;
using JsonSchemaNet = Json.Schema;
namespace ErsatzTV.Infrastructure.Scheduling; namespace ErsatzTV.Infrastructure.Scheduling;
public class SequentialScheduleValidator(ILogger<SequentialScheduleValidator> logger) : ISequentialScheduleValidator public class SequentialScheduleValidator(IFileSystem fileSystem, ILogger<SequentialScheduleValidator> logger)
: ISequentialScheduleValidator
{ {
public async Task<bool> ValidateSchedule(string yaml, bool isImport) public async Task<bool> ValidateSchedule(string yaml, bool isImport)
{ {
try try
{ {
string schemaFileName = Path.Combine( string schemaFileName = GetSchemaPath(isImport);
FileSystemLayout.ResourcesCacheFolder, string schemaText = await fileSystem.File.ReadAllTextAsync(schemaFileName);
isImport ? "sequential-schedule-import.schema.json" : "sequential-schedule.schema.json");
using StreamReader sr = File.OpenText(schemaFileName); JsonSchemaNet.JsonSchema schema = JsonSchemaNet.JsonSchema.FromText(schemaText);
await using var reader = new JsonTextReader(sr);
var schema = JSchema.Load(reader); string jsonString = ConvertYamlToJsonString(yaml);
JsonNode jsonNode = JsonNode.Parse(jsonString);
using var textReader = new StringReader(yaml); JsonSchemaNet.EvaluationResults result = schema.Evaluate(jsonNode);
var yamlStream = new YamlStream();
yamlStream.Load(textReader);
var schedule = JObject.Parse(Convert(yamlStream));
if (!schedule.IsValid(schema, out IList<string> errorMessages)) if (!result.IsValid)
{ {
logger.LogWarning("Failed to validate sequential schedule definition: {ErrorMessages}", errorMessages); logger.LogWarning("Sequential schedule definition failed validation");
return false; return false;
} }
@ -47,30 +49,27 @@ public class SequentialScheduleValidator(ILogger<SequentialScheduleValidator> lo
public string ToJson(string yaml) public string ToJson(string yaml)
{ {
using var textReader = new StringReader(yaml); string jsonString = ConvertYamlToJsonString(yaml);
var yamlStream = new YamlStream(); var schedule = JObject.Parse(jsonString);
yamlStream.Load(textReader);
var schedule = JObject.Parse(Convert(yamlStream));
string formatted = JsonConvert.SerializeObject(schedule, Formatting.Indented); string formatted = JsonConvert.SerializeObject(schedule, Formatting.Indented);
string[] lines = formatted.Split('\n'); string[] lines = formatted.Split('\n');
return string.Join('\n', lines.Select((line, index) => $"{index + 1,4}: {line}")); return string.Join('\n', lines.Select((line, index) => $"{index + 1,4}: {line}"));
} }
// limited to 1000/hr, but only called manually from UI
public async Task<IList<string>> GetValidationMessages(string yaml, bool isImport) public async Task<IList<string>> GetValidationMessages(string yaml, bool isImport)
{ {
try try
{ {
string schemaFileName = Path.Combine( string schemaFileName = GetSchemaPath(isImport);
FileSystemLayout.ResourcesCacheFolder,
isImport ? "sequential-schedule-import.schema.json" : "sequential-schedule.schema.json"); using StreamReader sr = fileSystem.File.OpenText(schemaFileName);
using StreamReader sr = File.OpenText(schemaFileName);
await using var reader = new JsonTextReader(sr); await using var reader = new JsonTextReader(sr);
var schema = JSchema.Load(reader); var schema = JSchema.Load(reader);
using var textReader = new StringReader(yaml); string jsonString = ConvertYamlToJsonString(yaml);
var yamlStream = new YamlStream(); var schedule = JObject.Parse(jsonString);
yamlStream.Load(textReader);
var schedule = JObject.Parse(Convert(yamlStream));
return schedule.IsValid(schema, out IList<string> errorMessages) ? [] : errorMessages; return schedule.IsValid(schema, out IList<string> errorMessages) ? [] : errorMessages;
} }
@ -80,13 +79,24 @@ public class SequentialScheduleValidator(ILogger<SequentialScheduleValidator> lo
} }
} }
private static string Convert(YamlStream yamlStream) private static string ConvertYamlToJsonString(string yaml)
{ {
using var textReader = new StringReader(yaml);
var yamlStream = new YamlStream();
yamlStream.Load(textReader);
var visitor = new YamlToJsonVisitor(); var visitor = new YamlToJsonVisitor();
yamlStream.Accept(visitor); yamlStream.Accept(visitor);
return JsonConvert.SerializeObject(JsonConvert.DeserializeObject(visitor.JsonString), Formatting.Indented); return JsonConvert.SerializeObject(JsonConvert.DeserializeObject(visitor.JsonString), Formatting.Indented);
} }
private static string GetSchemaPath(bool isImport)
{
return Path.Combine(
FileSystemLayout.ResourcesCacheFolder,
isImport ? "sequential-schedule-import.schema.json" : "sequential-schedule.schema.json");
}
private sealed class YamlToJsonVisitor : IYamlVisitor private sealed class YamlToJsonVisitor : IYamlVisitor
{ {
private readonly JsonSerializerOptions _options = new() { WriteIndented = false }; private readonly JsonSerializerOptions _options = new() { WriteIndented = false };

13
ErsatzTV.Infrastructure/Streaming/ExternalJsonPlayoutItemProvider.cs

@ -1,4 +1,5 @@
using System.Globalization; using System.Globalization;
using System.IO.Abstractions;
using ErsatzTV.Core; using ErsatzTV.Core;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Filler; using ErsatzTV.Core.Domain.Filler;
@ -19,7 +20,7 @@ namespace ErsatzTV.Infrastructure.Streaming;
public class ExternalJsonPlayoutItemProvider : IExternalJsonPlayoutItemProvider public class ExternalJsonPlayoutItemProvider : IExternalJsonPlayoutItemProvider
{ {
private readonly IDbContextFactory<TvContext> _dbContextFactory; private readonly IDbContextFactory<TvContext> _dbContextFactory;
private readonly ILocalFileSystem _localFileSystem; private readonly IFileSystem _fileSystem;
private readonly ILocalStatisticsProvider _localStatisticsProvider; private readonly ILocalStatisticsProvider _localStatisticsProvider;
private readonly ILogger<ExternalJsonPlayoutItemProvider> _logger; private readonly ILogger<ExternalJsonPlayoutItemProvider> _logger;
private readonly IPlexPathReplacementService _plexPathReplacementService; private readonly IPlexPathReplacementService _plexPathReplacementService;
@ -28,7 +29,7 @@ public class ExternalJsonPlayoutItemProvider : IExternalJsonPlayoutItemProvider
public ExternalJsonPlayoutItemProvider( public ExternalJsonPlayoutItemProvider(
IDbContextFactory<TvContext> dbContextFactory, IDbContextFactory<TvContext> dbContextFactory,
ILocalFileSystem localFileSystem, IFileSystem fileSystem,
IPlexPathReplacementService plexPathReplacementService, IPlexPathReplacementService plexPathReplacementService,
IPlexServerApiClient plexServerApiClient, IPlexServerApiClient plexServerApiClient,
IPlexSecretStore plexSecretStore, IPlexSecretStore plexSecretStore,
@ -36,7 +37,7 @@ public class ExternalJsonPlayoutItemProvider : IExternalJsonPlayoutItemProvider
ILogger<ExternalJsonPlayoutItemProvider> logger) ILogger<ExternalJsonPlayoutItemProvider> logger)
{ {
_dbContextFactory = dbContextFactory; _dbContextFactory = dbContextFactory;
_localFileSystem = localFileSystem; _fileSystem = fileSystem;
_plexPathReplacementService = plexPathReplacementService; _plexPathReplacementService = plexPathReplacementService;
_plexServerApiClient = plexServerApiClient; _plexServerApiClient = plexServerApiClient;
_plexSecretStore = plexSecretStore; _plexSecretStore = plexSecretStore;
@ -62,7 +63,7 @@ public class ExternalJsonPlayoutItemProvider : IExternalJsonPlayoutItemProvider
if (playout.ScheduleKind == PlayoutScheduleKind.ExternalJson) if (playout.ScheduleKind == PlayoutScheduleKind.ExternalJson)
{ {
// json file must exist // json file must exist
if (_localFileSystem.FileExists(playout.ScheduleFile)) if (_fileSystem.File.Exists(playout.ScheduleFile))
{ {
return await GetExternalJsonPlayoutItem(dbContext, playout, now, ffprobePath, cancellationToken); return await GetExternalJsonPlayoutItem(dbContext, playout, now, ffprobePath, cancellationToken);
} }
@ -86,7 +87,7 @@ public class ExternalJsonPlayoutItemProvider : IExternalJsonPlayoutItemProvider
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
Option<ExternalJsonChannel> maybeChannel = JsonConvert.DeserializeObject<ExternalJsonChannel>( Option<ExternalJsonChannel> maybeChannel = JsonConvert.DeserializeObject<ExternalJsonChannel>(
await File.ReadAllTextAsync(playout.ScheduleFile, cancellationToken)); await _fileSystem.File.ReadAllTextAsync(playout.ScheduleFile, cancellationToken));
// must deserialize channel from json // must deserialize channel from json
foreach (ExternalJsonChannel channel in maybeChannel) foreach (ExternalJsonChannel channel in maybeChannel)
@ -139,7 +140,7 @@ public class ExternalJsonPlayoutItemProvider : IExternalJsonPlayoutItemProvider
program.File, program.File,
cancellationToken); cancellationToken);
if (_localFileSystem.FileExists(localPath)) if (_fileSystem.File.Exists(localPath))
{ {
return await StreamLocally(startTime, program, ffprobePath, localPath); return await StreamLocally(startTime, program, ffprobePath, localPath);
} }

10
ErsatzTV.Infrastructure/Streaming/Graphics/GraphicsElementLoader.cs

@ -1,8 +1,8 @@
using System.IO.Abstractions;
using System.Text; using System.Text;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Graphics; using ErsatzTV.Core.Graphics;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Streaming; using ErsatzTV.Core.Interfaces.Streaming;
using ErsatzTV.Core.Metadata; using ErsatzTV.Core.Metadata;
@ -18,7 +18,7 @@ namespace ErsatzTV.Infrastructure.Streaming.Graphics;
public partial class GraphicsElementLoader( public partial class GraphicsElementLoader(
TemplateFunctions templateFunctions, TemplateFunctions templateFunctions,
ILocalFileSystem localFileSystem, IFileSystem fileSystem,
ITemplateDataRepository templateDataRepository, ITemplateDataRepository templateDataRepository,
ILogger<GraphicsElementLoader> logger) ILogger<GraphicsElementLoader> logger)
: IGraphicsElementLoader : IGraphicsElementLoader
@ -147,7 +147,7 @@ public partial class GraphicsElementLoader(
{ {
try try
{ {
string yaml = await localFileSystem.ReadAllText(fileName); string yaml = await fileSystem.File.ReadAllTextAsync(fileName, cancellationToken);
var template = Template.Parse(yaml); var template = Template.Parse(yaml);
var builder = new StringBuilder(); var builder = new StringBuilder();
@ -187,7 +187,7 @@ public partial class GraphicsElementLoader(
foreach (var reference in elementsWithEpg) foreach (var reference in elementsWithEpg)
{ {
foreach (string line in await localFileSystem.ReadAllLines(reference.GraphicsElement.Path)) foreach (string line in await fileSystem.File.ReadAllLinesAsync(reference.GraphicsElement.Path))
{ {
Match match = EpgEntriesRegex().Match(line); Match match = EpgEntriesRegex().Match(line);
if (!match.Success || !int.TryParse(match.Groups[1].Value, out int value)) if (!match.Success || !int.TryParse(match.Groups[1].Value, out int value))
@ -257,7 +257,7 @@ public partial class GraphicsElementLoader(
private async Task<Option<string>> GetTemplatedYaml(string fileName, Dictionary<string, object> variables) private async Task<Option<string>> GetTemplatedYaml(string fileName, Dictionary<string, object> variables)
{ {
string yaml = await localFileSystem.ReadAllText(fileName); string yaml = await fileSystem.File.ReadAllTextAsync(fileName);
try try
{ {
var scriptObject = new ScriptObject(); var scriptObject = new ScriptObject();

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

@ -34,6 +34,7 @@ using NSubstitute;
using NUnit.Framework; using NUnit.Framework;
using Serilog; using Serilog;
using Shouldly; using Shouldly;
using Testably.Abstractions.Testing;
using MediaStream = ErsatzTV.Core.Domain.MediaStream; using MediaStream = ErsatzTV.Core.Domain.MediaStream;
namespace ErsatzTV.Scanner.Tests.Core.FFmpeg; namespace ErsatzTV.Scanner.Tests.Core.FFmpeg;
@ -235,8 +236,10 @@ public class TranscodingTests
StreamingMode streamingMode) StreamingMode streamingMode)
{ {
var localFileSystem = new LocalFileSystem( var localFileSystem = new LocalFileSystem(
new MockFileSystem(),
Substitute.For<IClient>(), Substitute.For<IClient>(),
LoggerFactory.CreateLogger<LocalFileSystem>()); LoggerFactory.CreateLogger<LocalFileSystem>());
var fileSystem = new MockFileSystem();
var tempFilePool = new TempFilePool(); var tempFilePool = new TempFilePool();
ImageCache mockImageCache = Substitute.For<ImageCache>(localFileSystem, tempFilePool); ImageCache mockImageCache = Substitute.For<ImageCache>(localFileSystem, tempFilePool);
@ -353,7 +356,8 @@ public class TranscodingTests
var localStatisticsProvider = new LocalStatisticsProvider( var localStatisticsProvider = new LocalStatisticsProvider(
metadataRepository, metadataRepository,
new LocalFileSystem(Substitute.For<IClient>(), LoggerFactory.CreateLogger<LocalFileSystem>()), fileSystem,
localFileSystem,
Substitute.For<IClient>(), Substitute.For<IClient>(),
Substitute.For<IHardwareCapabilitiesFactory>(), Substitute.For<IHardwareCapabilitiesFactory>(),
LoggerFactory.CreateLogger<LocalStatisticsProvider>()); LoggerFactory.CreateLogger<LocalStatisticsProvider>());
@ -460,6 +464,12 @@ public class TranscodingTests
// do nothing // do nothing
} }
var localFileSystem = new LocalFileSystem(
new MockFileSystem(),
Substitute.For<IClient>(),
LoggerFactory.CreateLogger<LocalFileSystem>());
var fileSystem = new MockFileSystem();
string file = fileToTest; string file = fileToTest;
if (string.IsNullOrWhiteSpace(file)) if (string.IsNullOrWhiteSpace(file))
{ {
@ -503,7 +513,8 @@ public class TranscodingTests
var localStatisticsProvider = new LocalStatisticsProvider( var localStatisticsProvider = new LocalStatisticsProvider(
metadataRepository, metadataRepository,
new LocalFileSystem(Substitute.For<IClient>(), LoggerFactory.CreateLogger<LocalFileSystem>()), fileSystem,
localFileSystem,
Substitute.For<IClient>(), Substitute.For<IClient>(),
Substitute.For<IHardwareCapabilitiesFactory>(), Substitute.For<IHardwareCapabilitiesFactory>(),
LoggerFactory.CreateLogger<LocalStatisticsProvider>()); LoggerFactory.CreateLogger<LocalStatisticsProvider>());
@ -669,9 +680,6 @@ public class TranscodingTests
SubtitleMode = subtitleMode SubtitleMode = subtitleMode
}; };
var localFileSystem = new LocalFileSystem(
Substitute.For<IClient>(),
LoggerFactory.CreateLogger<LocalFileSystem>());
var tempFilePool = new TempFilePool(); var tempFilePool = new TempFilePool();
ImageCache mockImageCache = Substitute.For<ImageCache>(localFileSystem, tempFilePool); ImageCache mockImageCache = Substitute.For<ImageCache>(localFileSystem, tempFilePool);

88
ErsatzTV.Scanner.Tests/Core/Fakes/FakeLocalFileSystem.cs

@ -1,88 +0,0 @@
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Metadata;
namespace ErsatzTV.Scanner.Tests.Core.Fakes;
public class FakeLocalFileSystem : ILocalFileSystem
{
private readonly List<FakeFileEntry> _files;
private readonly List<FakeFolderEntry> _folders;
public FakeLocalFileSystem(List<FakeFileEntry> files) : this(files, new List<FakeFolderEntry>())
{
}
public FakeLocalFileSystem(List<FakeFileEntry> files, List<FakeFolderEntry> folders)
{
_files = files;
var allFolders = new List<string>(folders.Map(f => f.Path));
foreach (FakeFileEntry file in _files)
{
List<DirectoryInfo> moreFolders =
Split(new DirectoryInfo(Path.GetDirectoryName(file.Path) ?? string.Empty));
allFolders.AddRange(moreFolders.Map(i => i.FullName));
}
_folders = allFolders.Distinct().Map(f => new FakeFolderEntry(f)).ToList();
}
public Unit EnsureFolderExists(string folder) => Unit.Default;
public DateTime GetLastWriteTime(string path) =>
Optional(_files.SingleOrDefault(f => f.Path == path))
.Map(f => f.LastWriteTime)
.IfNone(SystemTime.MinValueUtc);
public bool IsLibraryPathAccessible(LibraryPath libraryPath) =>
_folders.Any(f => f.Path == libraryPath.Path);
public IEnumerable<string> ListSubdirectories(string folder) =>
_folders.Map(f => f.Path).Filter(f => f.StartsWith(folder) && Directory.GetParent(f)?.FullName == folder);
public IEnumerable<string> ListFiles(string folder) =>
_files.Map(f => f.Path).Filter(f => Path.GetDirectoryName(f) == folder);
// TODO: this isn't accurate, need to use search pattern
public IEnumerable<string> ListFiles(string folder, string searchPattern) =>
_files.Map(f => f.Path).Filter(f => Path.GetDirectoryName(f) == folder);
public IEnumerable<string> ListFiles(string folder, params string[] searchPatterns) =>
_files.Map(f => f.Path).Filter(f => Path.GetDirectoryName(f) == folder);
public bool FileExists(string path) => _files.Any(f => f.Path == path);
public bool FolderExists(string folder) => false;
public Task<Either<BaseError, Unit>> CopyFile(string source, string destination) =>
Task.FromResult(Right<BaseError, Unit>(Unit.Default));
public Unit EmptyFolder(string folder) => Unit.Default;
public Task<string> ReadAllText(string path) => throw new NotImplementedException();
public Task<string[]> ReadAllLines(string path) => throw new NotImplementedException();
public Task<byte[]> GetHash(string path) => throw new NotImplementedException();
public string GetCustomOrDefaultFile(string folder, string file)
{
string path = Path.Combine(folder, file);
return FileExists(path) ? path : Path.Combine(folder, $"_{file}");
}
private static List<DirectoryInfo> Split(DirectoryInfo path)
{
var result = new List<DirectoryInfo>();
if (path == null || string.IsNullOrWhiteSpace(path.FullName))
{
return result;
}
if (path.Parent != null)
{
result.AddRange(Split(path.Parent));
}
result.Add(path);
return result;
}
}

22
ErsatzTV.Scanner.Tests/Core/Metadata/LocalSubtitlesProviderTests.cs

@ -1,12 +1,16 @@
using System.Globalization; using System.Globalization;
using Bugsnag;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Metadata;
using ErsatzTV.Scanner.Core.Metadata; using ErsatzTV.Scanner.Core.Metadata;
using ErsatzTV.Scanner.Tests.Core.Fakes; using ErsatzTV.Scanner.Tests.Core.Fakes;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using NSubstitute; using NSubstitute;
using NUnit.Framework; using NUnit.Framework;
using Shouldly; using Shouldly;
using Testably.Abstractions.Testing;
using Testably.Abstractions.Testing.Initializer;
namespace ErsatzTV.Scanner.Tests.Core.Metadata; namespace ErsatzTV.Scanner.Tests.Core.Metadata;
@ -46,10 +50,17 @@ public class LocalSubtitlesProviderTests
new(@"/Movies/Avatar (2009)/Avatar (2009).DE.SDH.FORCED.SRT") new(@"/Movies/Avatar (2009)/Avatar (2009).DE.SDH.FORCED.SRT")
}; };
var fileSystem = new MockFileSystem();
IFileSystemInitializer<MockFileSystem> init = fileSystem.Initialize();
foreach (var file in fakeFiles)
{
init.WithFile(file.Path);
}
var provider = new LocalSubtitlesProvider( var provider = new LocalSubtitlesProvider(
Substitute.For<IMediaItemRepository>(), Substitute.For<IMediaItemRepository>(),
Substitute.For<IMetadataRepository>(), Substitute.For<IMetadataRepository>(),
new FakeLocalFileSystem(fakeFiles), new LocalFileSystem(fileSystem, Substitute.For<IClient>(), Substitute.For<ILogger<LocalFileSystem>>()),
Substitute.For<ILogger<LocalSubtitlesProvider>>()); Substitute.For<ILogger<LocalSubtitlesProvider>>());
List<Subtitle> result = provider.LocateExternalSubtitles( List<Subtitle> result = provider.LocateExternalSubtitles(
@ -91,10 +102,17 @@ public class LocalSubtitlesProviderTests
new(@"/Movies/Avatar (2009)/Avatar (2009).DE.SDH.FORCED.SRT") new(@"/Movies/Avatar (2009)/Avatar (2009).DE.SDH.FORCED.SRT")
}; };
var fileSystem = new MockFileSystem();
IFileSystemInitializer<MockFileSystem> init = fileSystem.Initialize();
foreach (var file in fakeFiles)
{
init.WithFile(file.Path);
}
var provider = new LocalSubtitlesProvider( var provider = new LocalSubtitlesProvider(
Substitute.For<IMediaItemRepository>(), Substitute.For<IMediaItemRepository>(),
Substitute.For<IMetadataRepository>(), Substitute.For<IMetadataRepository>(),
new FakeLocalFileSystem(fakeFiles), new LocalFileSystem(fileSystem, Substitute.For<IClient>(), Substitute.For<ILogger<LocalFileSystem>>()),
Substitute.For<ILogger<LocalSubtitlesProvider>>()); Substitute.For<ILogger<LocalSubtitlesProvider>>());
List<Subtitle> result = provider.LocateExternalSubtitles( List<Subtitle> result = provider.LocateExternalSubtitles(

38
ErsatzTV.Scanner.Tests/Core/Metadata/MovieFolderScannerTests.cs

@ -17,16 +17,14 @@ using NSubstitute;
using NUnit.Framework; using NUnit.Framework;
using Serilog; using Serilog;
using Shouldly; using Shouldly;
using Testably.Abstractions.Testing;
using Testably.Abstractions.Testing.Initializer;
namespace ErsatzTV.Scanner.Tests.Core.Metadata; namespace ErsatzTV.Scanner.Tests.Core.Metadata;
[TestFixture] [TestFixture]
public class MovieFolderScannerTests public class MovieFolderScannerTests
{ {
private static readonly string BadFakeRoot = RuntimeInformation.IsOSPlatform(OSPlatform.Windows)
? @"C:\Movies-That-Dont-Exist"
: @"/movies-that-dont-exist";
private static readonly string FakeRoot = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) private static readonly string FakeRoot = RuntimeInformation.IsOSPlatform(OSPlatform.Windows)
? @"C:\Movies" ? @"C:\Movies"
: "/movies"; : "/movies";
@ -718,10 +716,19 @@ public class MovieFolderScannerTests
await _mediaItemRepository.Received(1).FlagFileNotFound(libraryPath, oldMoviePath); await _mediaItemRepository.Received(1).FlagFileNotFound(libraryPath, oldMoviePath);
} }
private MovieFolderScanner GetService(params FakeFileEntry[] files) => private MovieFolderScanner GetService(params FakeFileEntry[] files)
new( {
var fileSystem = new MockFileSystem();
IFileSystemInitializer<MockFileSystem> init = fileSystem.Initialize();
foreach (var file in files)
{
init.WithFile(file.Path).Which(f => f.File.LastWriteTime = file.LastWriteTime);
}
return new MovieFolderScanner(
_scannerProxy, _scannerProxy,
new FakeLocalFileSystem([..files]), fileSystem,
new LocalFileSystem(fileSystem, Substitute.For<IClient>(), Substitute.For<ILogger<LocalFileSystem>>()),
_movieRepository, _movieRepository,
_localStatisticsProvider, _localStatisticsProvider,
Substitute.For<ILocalSubtitlesProvider>(), Substitute.For<ILocalSubtitlesProvider>(),
@ -735,11 +742,21 @@ public class MovieFolderScannerTests
Substitute.For<ITempFilePool>(), Substitute.For<ITempFilePool>(),
Substitute.For<IClient>(), Substitute.For<IClient>(),
Logger); Logger);
}
private MovieFolderScanner GetService(params FakeFolderEntry[] folders) => private MovieFolderScanner GetService(params FakeFolderEntry[] folders)
new( {
var fileSystem = new MockFileSystem();
IFileSystemInitializer<MockFileSystem> init = fileSystem.Initialize();
foreach (var folder in folders)
{
init.WithSubdirectory(folder.Path);
}
return new MovieFolderScanner(
_scannerProxy, _scannerProxy,
new FakeLocalFileSystem([], [..folders]), fileSystem,
new LocalFileSystem(fileSystem, Substitute.For<IClient>(), Substitute.For<ILogger<LocalFileSystem>>()),
_movieRepository, _movieRepository,
_localStatisticsProvider, _localStatisticsProvider,
Substitute.For<ILocalSubtitlesProvider>(), Substitute.For<ILocalSubtitlesProvider>(),
@ -753,5 +770,6 @@ public class MovieFolderScannerTests
Substitute.For<ITempFilePool>(), Substitute.For<ITempFilePool>(),
Substitute.For<IClient>(), Substitute.For<IClient>(),
Logger); Logger);
}
} }
} }

1
ErsatzTV.Scanner.Tests/ErsatzTV.Scanner.Tests.csproj

@ -23,6 +23,7 @@
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
</PackageReference> </PackageReference>
<PackageReference Include="Shouldly" Version="4.3.0" /> <PackageReference Include="Shouldly" Version="4.3.0" />
<PackageReference Include="Testably.Abstractions.Testing" Version="5.0.0" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>

8
ErsatzTV.Scanner/Core/Emby/EmbyMovieLibraryScanner.cs

@ -1,9 +1,9 @@
using ErsatzTV.Core; using System.IO.Abstractions;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Emby; using ErsatzTV.Core.Emby;
using ErsatzTV.Core.Extensions; using ErsatzTV.Core.Extensions;
using ErsatzTV.Core.Interfaces.Emby; using ErsatzTV.Core.Interfaces.Emby;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Metadata; using ErsatzTV.Core.Metadata;
using ErsatzTV.Scanner.Core.Interfaces; using ErsatzTV.Scanner.Core.Interfaces;
@ -29,13 +29,13 @@ public class EmbyMovieLibraryScanner :
IMediaSourceRepository mediaSourceRepository, IMediaSourceRepository mediaSourceRepository,
IEmbyMovieRepository embyMovieRepository, IEmbyMovieRepository embyMovieRepository,
IEmbyPathReplacementService pathReplacementService, IEmbyPathReplacementService pathReplacementService,
ILocalFileSystem localFileSystem, IFileSystem fileSystem,
ILocalChaptersProvider localChaptersProvider, ILocalChaptersProvider localChaptersProvider,
IMetadataRepository metadataRepository, IMetadataRepository metadataRepository,
ILogger<EmbyMovieLibraryScanner> logger) ILogger<EmbyMovieLibraryScanner> logger)
: base( : base(
scannerProxy, scannerProxy,
localFileSystem, fileSystem,
localChaptersProvider, localChaptersProvider,
metadataRepository, metadataRepository,
logger) logger)

8
ErsatzTV.Scanner/Core/Emby/EmbyTelevisionLibraryScanner.cs

@ -1,10 +1,10 @@
using ErsatzTV.Core; using System.IO.Abstractions;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Emby; using ErsatzTV.Core.Emby;
using ErsatzTV.Core.Errors; using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Extensions; using ErsatzTV.Core.Extensions;
using ErsatzTV.Core.Interfaces.Emby; using ErsatzTV.Core.Interfaces.Emby;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Metadata; using ErsatzTV.Core.Metadata;
using ErsatzTV.Scanner.Core.Interfaces; using ErsatzTV.Scanner.Core.Interfaces;
@ -30,13 +30,13 @@ public class EmbyTelevisionLibraryScanner : MediaServerTelevisionLibraryScanner<
IMediaSourceRepository mediaSourceRepository, IMediaSourceRepository mediaSourceRepository,
IEmbyTelevisionRepository televisionRepository, IEmbyTelevisionRepository televisionRepository,
IEmbyPathReplacementService pathReplacementService, IEmbyPathReplacementService pathReplacementService,
ILocalFileSystem localFileSystem, IFileSystem fileSystem,
ILocalChaptersProvider localChaptersProvider, ILocalChaptersProvider localChaptersProvider,
IMetadataRepository metadataRepository, IMetadataRepository metadataRepository,
ILogger<EmbyTelevisionLibraryScanner> logger) ILogger<EmbyTelevisionLibraryScanner> logger)
: base( : base(
scannerProxy, scannerProxy,
localFileSystem, fileSystem,
localChaptersProvider, localChaptersProvider,
metadataRepository, metadataRepository,
logger) logger)

8
ErsatzTV.Scanner/Core/Jellyfin/JellyfinMovieLibraryScanner.cs

@ -1,8 +1,8 @@
using ErsatzTV.Core; using System.IO.Abstractions;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Extensions; using ErsatzTV.Core.Extensions;
using ErsatzTV.Core.Interfaces.Jellyfin; using ErsatzTV.Core.Interfaces.Jellyfin;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Jellyfin; using ErsatzTV.Core.Jellyfin;
using ErsatzTV.Core.Metadata; using ErsatzTV.Core.Metadata;
@ -29,13 +29,13 @@ public class JellyfinMovieLibraryScanner :
IJellyfinMovieRepository jellyfinMovieRepository, IJellyfinMovieRepository jellyfinMovieRepository,
IJellyfinPathReplacementService pathReplacementService, IJellyfinPathReplacementService pathReplacementService,
IMediaSourceRepository mediaSourceRepository, IMediaSourceRepository mediaSourceRepository,
ILocalFileSystem localFileSystem, IFileSystem fileSystem,
ILocalChaptersProvider localChaptersProvider, ILocalChaptersProvider localChaptersProvider,
IMetadataRepository metadataRepository, IMetadataRepository metadataRepository,
ILogger<JellyfinMovieLibraryScanner> logger) ILogger<JellyfinMovieLibraryScanner> logger)
: base( : base(
scannerProxy, scannerProxy,
localFileSystem, fileSystem,
localChaptersProvider, localChaptersProvider,
metadataRepository, metadataRepository,
logger) logger)

8
ErsatzTV.Scanner/Core/Jellyfin/JellyfinTelevisionLibraryScanner.cs

@ -1,9 +1,9 @@
using ErsatzTV.Core; using System.IO.Abstractions;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Errors; using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Extensions; using ErsatzTV.Core.Extensions;
using ErsatzTV.Core.Interfaces.Jellyfin; using ErsatzTV.Core.Interfaces.Jellyfin;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Jellyfin; using ErsatzTV.Core.Jellyfin;
using ErsatzTV.Core.Metadata; using ErsatzTV.Core.Metadata;
@ -31,13 +31,13 @@ public class JellyfinTelevisionLibraryScanner : MediaServerTelevisionLibraryScan
IMediaSourceRepository mediaSourceRepository, IMediaSourceRepository mediaSourceRepository,
IJellyfinTelevisionRepository televisionRepository, IJellyfinTelevisionRepository televisionRepository,
IJellyfinPathReplacementService pathReplacementService, IJellyfinPathReplacementService pathReplacementService,
ILocalFileSystem localFileSystem, IFileSystem fileSystem,
ILocalChaptersProvider localChaptersProvider, ILocalChaptersProvider localChaptersProvider,
IMetadataRepository metadataRepository, IMetadataRepository metadataRepository,
ILogger<JellyfinTelevisionLibraryScanner> logger) ILogger<JellyfinTelevisionLibraryScanner> logger)
: base( : base(
scannerProxy, scannerProxy,
localFileSystem, fileSystem,
localChaptersProvider, localChaptersProvider,
metadataRepository, metadataRepository,
logger) logger)

8
ErsatzTV.Scanner/Core/Metadata/ImageFolderScanner.cs

@ -1,4 +1,5 @@
using System.Collections.Immutable; using System.Collections.Immutable;
using System.IO.Abstractions;
using Bugsnag; using Bugsnag;
using ErsatzTV.Core; using ErsatzTV.Core;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
@ -22,6 +23,7 @@ public class ImageFolderScanner : LocalFolderScanner, IImageFolderScanner
private readonly IImageRepository _imageRepository; private readonly IImageRepository _imageRepository;
private readonly ILibraryRepository _libraryRepository; private readonly ILibraryRepository _libraryRepository;
private readonly IScannerProxy _scannerProxy; private readonly IScannerProxy _scannerProxy;
private readonly IFileSystem _fileSystem;
private readonly ILocalFileSystem _localFileSystem; private readonly ILocalFileSystem _localFileSystem;
private readonly ILocalMetadataProvider _localMetadataProvider; private readonly ILocalMetadataProvider _localMetadataProvider;
private readonly ILogger<ImageFolderScanner> _logger; private readonly ILogger<ImageFolderScanner> _logger;
@ -29,6 +31,7 @@ public class ImageFolderScanner : LocalFolderScanner, IImageFolderScanner
public ImageFolderScanner( public ImageFolderScanner(
IScannerProxy scannerProxy, IScannerProxy scannerProxy,
IFileSystem fileSystem,
ILocalFileSystem localFileSystem, ILocalFileSystem localFileSystem,
ILocalStatisticsProvider localStatisticsProvider, ILocalStatisticsProvider localStatisticsProvider,
ILocalMetadataProvider localMetadataProvider, ILocalMetadataProvider localMetadataProvider,
@ -41,7 +44,7 @@ public class ImageFolderScanner : LocalFolderScanner, IImageFolderScanner
ITempFilePool tempFilePool, ITempFilePool tempFilePool,
IClient client, IClient client,
ILogger<ImageFolderScanner> logger) : base( ILogger<ImageFolderScanner> logger) : base(
localFileSystem, fileSystem,
localStatisticsProvider, localStatisticsProvider,
metadataRepository, metadataRepository,
mediaItemRepository, mediaItemRepository,
@ -52,6 +55,7 @@ public class ImageFolderScanner : LocalFolderScanner, IImageFolderScanner
logger) logger)
{ {
_scannerProxy = scannerProxy; _scannerProxy = scannerProxy;
_fileSystem = fileSystem;
_localFileSystem = localFileSystem; _localFileSystem = localFileSystem;
_localMetadataProvider = localMetadataProvider; _localMetadataProvider = localMetadataProvider;
_imageRepository = imageRepository; _imageRepository = imageRepository;
@ -226,7 +230,7 @@ public class ImageFolderScanner : LocalFolderScanner, IImageFolderScanner
foreach (string path in await _imageRepository.FindImagePaths(libraryPath)) foreach (string path in await _imageRepository.FindImagePaths(libraryPath))
{ {
if (!_localFileSystem.FileExists(path)) if (!_fileSystem.File.Exists(path))
{ {
_logger.LogInformation("Flagging missing image at {Path}", path); _logger.LogInformation("Flagging missing image at {Path}", path);
List<int> imageIds = await FlagFileNotFound(libraryPath, path); List<int> imageIds = await FlagFileNotFound(libraryPath, path);

13
ErsatzTV.Scanner/Core/Metadata/LocalFolderScanner.cs

@ -1,4 +1,5 @@
using System.Collections.Immutable; using System.Collections.Immutable;
using System.IO.Abstractions;
using Bugsnag; using Bugsnag;
using CliWrap; using CliWrap;
using ErsatzTV.Core; using ErsatzTV.Core;
@ -59,7 +60,7 @@ public abstract class LocalFolderScanner
private readonly IImageCache _imageCache; private readonly IImageCache _imageCache;
private readonly ILocalFileSystem _localFileSystem; private readonly IFileSystem _fileSystem;
private readonly ILocalStatisticsProvider _localStatisticsProvider; private readonly ILocalStatisticsProvider _localStatisticsProvider;
private readonly ILogger _logger; private readonly ILogger _logger;
private readonly IMediaItemRepository _mediaItemRepository; private readonly IMediaItemRepository _mediaItemRepository;
@ -67,7 +68,7 @@ public abstract class LocalFolderScanner
private readonly ITempFilePool _tempFilePool; private readonly ITempFilePool _tempFilePool;
protected LocalFolderScanner( protected LocalFolderScanner(
ILocalFileSystem localFileSystem, IFileSystem fileSystem,
ILocalStatisticsProvider localStatisticsProvider, ILocalStatisticsProvider localStatisticsProvider,
IMetadataRepository metadataRepository, IMetadataRepository metadataRepository,
IMediaItemRepository mediaItemRepository, IMediaItemRepository mediaItemRepository,
@ -77,7 +78,7 @@ public abstract class LocalFolderScanner
IClient client, IClient client,
ILogger logger) ILogger logger)
{ {
_localFileSystem = localFileSystem; _fileSystem = fileSystem;
_localStatisticsProvider = localStatisticsProvider; _localStatisticsProvider = localStatisticsProvider;
_metadataRepository = metadataRepository; _metadataRepository = metadataRepository;
_mediaItemRepository = mediaItemRepository; _mediaItemRepository = mediaItemRepository;
@ -100,7 +101,7 @@ public abstract class LocalFolderScanner
string path = version.MediaFiles.Head().Path; string path = version.MediaFiles.Head().Path;
if (version.DateUpdated != _localFileSystem.GetLastWriteTime(path) || version.Streams.Count == 0) if (version.DateUpdated != _fileSystem.File.GetLastWriteTime(path) || version.Streams.Count == 0)
{ {
_logger.LogDebug("Refreshing {Attribute} for {Path}", "Statistics", path); _logger.LogDebug("Refreshing {Attribute} for {Path}", "Statistics", path);
Either<BaseError, bool> refreshResult = Either<BaseError, bool> refreshResult =
@ -141,7 +142,7 @@ public abstract class LocalFolderScanner
Option<int> attachedPicIndex, Option<int> attachedPicIndex,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
DateTime lastWriteTime = _localFileSystem.GetLastWriteTime(artworkFile); DateTime lastWriteTime = _fileSystem.File.GetLastWriteTime(artworkFile);
metadata.Artwork ??= new List<Artwork>(); metadata.Artwork ??= new List<Artwork>();
@ -311,5 +312,5 @@ public abstract class LocalFolderScanner
protected bool ShouldIncludeFolder(string folder) => protected bool ShouldIncludeFolder(string folder) =>
!string.IsNullOrWhiteSpace(folder) && !string.IsNullOrWhiteSpace(folder) &&
!Path.GetFileName(folder).StartsWith('.') && !Path.GetFileName(folder).StartsWith('.') &&
!_localFileSystem.FileExists(Path.Combine(folder, ".etvignore")); !_fileSystem.File.Exists(Path.Combine(folder, ".etvignore"));
} }

10
ErsatzTV.Scanner/Core/Metadata/LocalMetadataProvider.cs

@ -1,4 +1,5 @@
using Bugsnag; using System.IO.Abstractions;
using Bugsnag;
using ErsatzTV.Core; using ErsatzTV.Core;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Extensions; using ErsatzTV.Core.Extensions;
@ -22,6 +23,7 @@ public class LocalMetadataProvider : ILocalMetadataProvider
private readonly IClient _client; private readonly IClient _client;
private readonly IEpisodeNfoReader _episodeNfoReader; private readonly IEpisodeNfoReader _episodeNfoReader;
private readonly IFallbackMetadataProvider _fallbackMetadataProvider; private readonly IFallbackMetadataProvider _fallbackMetadataProvider;
private readonly IFileSystem _fileSystem;
private readonly IImageRepository _imageRepository; private readonly IImageRepository _imageRepository;
private readonly ILocalFileSystem _localFileSystem; private readonly ILocalFileSystem _localFileSystem;
private readonly ILocalStatisticsProvider _localStatisticsProvider; private readonly ILocalStatisticsProvider _localStatisticsProvider;
@ -49,6 +51,7 @@ public class LocalMetadataProvider : ILocalMetadataProvider
IImageRepository imageRepository, IImageRepository imageRepository,
IRemoteStreamRepository remoteStreamRepository, IRemoteStreamRepository remoteStreamRepository,
IFallbackMetadataProvider fallbackMetadataProvider, IFallbackMetadataProvider fallbackMetadataProvider,
IFileSystem fileSystem,
ILocalFileSystem localFileSystem, ILocalFileSystem localFileSystem,
IMovieNfoReader movieNfoReader, IMovieNfoReader movieNfoReader,
IEpisodeNfoReader episodeNfoReader, IEpisodeNfoReader episodeNfoReader,
@ -70,6 +73,7 @@ public class LocalMetadataProvider : ILocalMetadataProvider
_imageRepository = imageRepository; _imageRepository = imageRepository;
_remoteStreamRepository = remoteStreamRepository; _remoteStreamRepository = remoteStreamRepository;
_fallbackMetadataProvider = fallbackMetadataProvider; _fallbackMetadataProvider = fallbackMetadataProvider;
_fileSystem = fileSystem;
_localFileSystem = localFileSystem; _localFileSystem = localFileSystem;
_movieNfoReader = movieNfoReader; _movieNfoReader = movieNfoReader;
_episodeNfoReader = episodeNfoReader; _episodeNfoReader = episodeNfoReader;
@ -86,7 +90,7 @@ public class LocalMetadataProvider : ILocalMetadataProvider
{ {
string nfoFileName = Path.Combine(showFolder, "tvshow.nfo"); string nfoFileName = Path.Combine(showFolder, "tvshow.nfo");
Option<ShowMetadata> maybeMetadata = None; Option<ShowMetadata> maybeMetadata = None;
if (_localFileSystem.FileExists(nfoFileName)) if (_fileSystem.File.Exists(nfoFileName))
{ {
maybeMetadata = await LoadTelevisionShowMetadata(nfoFileName); maybeMetadata = await LoadTelevisionShowMetadata(nfoFileName);
} }
@ -106,7 +110,7 @@ public class LocalMetadataProvider : ILocalMetadataProvider
{ {
string nfoFileName = Path.Combine(artistFolder, "artist.nfo"); string nfoFileName = Path.Combine(artistFolder, "artist.nfo");
Option<ArtistMetadata> maybeMetadata = None; Option<ArtistMetadata> maybeMetadata = None;
if (_localFileSystem.FileExists(nfoFileName)) if (_fileSystem.File.Exists(nfoFileName))
{ {
maybeMetadata = await LoadArtistMetadata(nfoFileName); maybeMetadata = await LoadArtistMetadata(nfoFileName);
} }

2
ErsatzTV.Scanner/Core/Metadata/LocalSubtitlesProvider.cs

@ -10,7 +10,7 @@ namespace ErsatzTV.Scanner.Core.Metadata;
public class LocalSubtitlesProvider : ILocalSubtitlesProvider public class LocalSubtitlesProvider : ILocalSubtitlesProvider
{ {
private readonly List<CultureInfo> _languageCodes = new(); private readonly List<CultureInfo> _languageCodes = [];
private readonly ILocalFileSystem _localFileSystem; private readonly ILocalFileSystem _localFileSystem;
private readonly ILogger<LocalSubtitlesProvider> _logger; private readonly ILogger<LocalSubtitlesProvider> _logger;
private readonly IMediaItemRepository _mediaItemRepository; private readonly IMediaItemRepository _mediaItemRepository;

14
ErsatzTV.Scanner/Core/Metadata/MediaServerMovieLibraryScanner.cs

@ -1,10 +1,10 @@
using System.Collections.Immutable; using System.Collections.Immutable;
using System.IO.Abstractions;
using ErsatzTV.Core; using ErsatzTV.Core;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.MediaServer; using ErsatzTV.Core.Domain.MediaServer;
using ErsatzTV.Core.Errors; using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Extensions; using ErsatzTV.Core.Extensions;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Metadata; using ErsatzTV.Core.Metadata;
using ErsatzTV.Scanner.Core.Interfaces; using ErsatzTV.Scanner.Core.Interfaces;
@ -21,19 +21,19 @@ public abstract class MediaServerMovieLibraryScanner<TConnectionParameters, TLib
{ {
private readonly ILocalChaptersProvider _localChaptersProvider; private readonly ILocalChaptersProvider _localChaptersProvider;
private readonly IScannerProxy _scannerProxy; private readonly IScannerProxy _scannerProxy;
private readonly ILocalFileSystem _localFileSystem; private readonly IFileSystem _fileSystem;
private readonly ILogger _logger; private readonly ILogger _logger;
private readonly IMetadataRepository _metadataRepository; private readonly IMetadataRepository _metadataRepository;
protected MediaServerMovieLibraryScanner( protected MediaServerMovieLibraryScanner(
IScannerProxy scannerProxy, IScannerProxy scannerProxy,
ILocalFileSystem localFileSystem, IFileSystem fileSystem,
ILocalChaptersProvider localChaptersProvider, ILocalChaptersProvider localChaptersProvider,
IMetadataRepository metadataRepository, IMetadataRepository metadataRepository,
ILogger logger) ILogger logger)
{ {
_scannerProxy = scannerProxy; _scannerProxy = scannerProxy;
_localFileSystem = localFileSystem; _fileSystem = fileSystem;
_localChaptersProvider = localChaptersProvider; _localChaptersProvider = localChaptersProvider;
_metadataRepository = metadataRepository; _metadataRepository = metadataRepository;
_logger = logger; _logger = logger;
@ -167,7 +167,7 @@ public abstract class MediaServerMovieLibraryScanner<TConnectionParameters, TLib
{ {
await movieRepository.SetEtag(result.Item, MediaServerEtag(incoming)); await movieRepository.SetEtag(result.Item, MediaServerEtag(incoming));
if (_localFileSystem.FileExists(result.LocalPath)) if (_fileSystem.File.Exists(result.LocalPath))
{ {
Option<int> flagResult = await movieRepository.FlagNormal(library, result.Item); Option<int> flagResult = await movieRepository.FlagNormal(library, result.Item);
if (flagResult.IsSome) if (flagResult.IsSome)
@ -270,7 +270,7 @@ public abstract class MediaServerMovieLibraryScanner<TConnectionParameters, TLib
existingEtag == MediaServerEtag(incoming)) existingEtag == MediaServerEtag(incoming))
{ {
// skip scanning unavailable/file not found items that are unchanged and still don't exist locally // skip scanning unavailable/file not found items that are unchanged and still don't exist locally
if (!_localFileSystem.FileExists(localPath) && !ServerSupportsRemoteStreaming) if (!_fileSystem.File.Exists(localPath) && !ServerSupportsRemoteStreaming)
{ {
return false; return false;
} }
@ -279,7 +279,7 @@ public abstract class MediaServerMovieLibraryScanner<TConnectionParameters, TLib
{ {
// item is unchanged, but file does not exist // item is unchanged, but file does not exist
// don't scan, but mark as unavailable // don't scan, but mark as unavailable
if (!_localFileSystem.FileExists(localPath)) if (!_fileSystem.File.Exists(localPath))
{ {
if (ServerSupportsRemoteStreaming) if (ServerSupportsRemoteStreaming)
{ {

14
ErsatzTV.Scanner/Core/Metadata/MediaServerOtherVideoLibraryScanner.cs

@ -1,10 +1,10 @@
using System.Collections.Immutable; using System.Collections.Immutable;
using System.IO.Abstractions;
using ErsatzTV.Core; using ErsatzTV.Core;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.MediaServer; using ErsatzTV.Core.Domain.MediaServer;
using ErsatzTV.Core.Errors; using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Extensions; using ErsatzTV.Core.Extensions;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Metadata; using ErsatzTV.Core.Metadata;
using ErsatzTV.Scanner.Core.Interfaces; using ErsatzTV.Scanner.Core.Interfaces;
@ -21,19 +21,19 @@ public abstract class MediaServerOtherVideoLibraryScanner<TConnectionParameters,
{ {
private readonly ILocalChaptersProvider _localChaptersProvider; private readonly ILocalChaptersProvider _localChaptersProvider;
private readonly IScannerProxy _scannerProxy; private readonly IScannerProxy _scannerProxy;
private readonly ILocalFileSystem _localFileSystem; private readonly IFileSystem _fileSystem;
private readonly ILogger _logger; private readonly ILogger _logger;
private readonly IMetadataRepository _metadataRepository; private readonly IMetadataRepository _metadataRepository;
protected MediaServerOtherVideoLibraryScanner( protected MediaServerOtherVideoLibraryScanner(
IScannerProxy scannerProxy, IScannerProxy scannerProxy,
ILocalFileSystem localFileSystem, IFileSystem fileSystem,
ILocalChaptersProvider localChaptersProvider, ILocalChaptersProvider localChaptersProvider,
IMetadataRepository metadataRepository, IMetadataRepository metadataRepository,
ILogger logger) ILogger logger)
{ {
_scannerProxy = scannerProxy; _scannerProxy = scannerProxy;
_localFileSystem = localFileSystem; _fileSystem = fileSystem;
_localChaptersProvider = localChaptersProvider; _localChaptersProvider = localChaptersProvider;
_metadataRepository = metadataRepository; _metadataRepository = metadataRepository;
_logger = logger; _logger = logger;
@ -174,7 +174,7 @@ public abstract class MediaServerOtherVideoLibraryScanner<TConnectionParameters,
{ {
await otherVideoRepository.SetEtag(result.Item, MediaServerEtag(incoming)); await otherVideoRepository.SetEtag(result.Item, MediaServerEtag(incoming));
if (_localFileSystem.FileExists(result.LocalPath)) if (_fileSystem.File.Exists(result.LocalPath))
{ {
Option<int> flagResult = await otherVideoRepository.FlagNormal(library, result.Item); Option<int> flagResult = await otherVideoRepository.FlagNormal(library, result.Item);
if (flagResult.IsSome) if (flagResult.IsSome)
@ -277,7 +277,7 @@ public abstract class MediaServerOtherVideoLibraryScanner<TConnectionParameters,
existingEtag == MediaServerEtag(incoming)) existingEtag == MediaServerEtag(incoming))
{ {
// skip scanning unavailable/file not found items that are unchanged and still don't exist locally // skip scanning unavailable/file not found items that are unchanged and still don't exist locally
if (!_localFileSystem.FileExists(localPath) && !ServerSupportsRemoteStreaming) if (!_fileSystem.File.Exists(localPath) && !ServerSupportsRemoteStreaming)
{ {
return false; return false;
} }
@ -286,7 +286,7 @@ public abstract class MediaServerOtherVideoLibraryScanner<TConnectionParameters,
{ {
// item is unchanged, but file does not exist // item is unchanged, but file does not exist
// don't scan, but mark as unavailable // don't scan, but mark as unavailable
if (!_localFileSystem.FileExists(localPath)) if (!_fileSystem.File.Exists(localPath))
{ {
if (ServerSupportsRemoteStreaming) if (ServerSupportsRemoteStreaming)
{ {

18
ErsatzTV.Scanner/Core/Metadata/MediaServerTelevisionLibraryScanner.cs

@ -1,9 +1,9 @@
using ErsatzTV.Core; using System.IO.Abstractions;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.MediaServer; using ErsatzTV.Core.Domain.MediaServer;
using ErsatzTV.Core.Errors; using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Extensions; using ErsatzTV.Core.Extensions;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Metadata; using ErsatzTV.Core.Metadata;
using ErsatzTV.Scanner.Core.Interfaces; using ErsatzTV.Scanner.Core.Interfaces;
@ -23,19 +23,19 @@ public abstract class MediaServerTelevisionLibraryScanner<TConnectionParameters,
{ {
private readonly ILocalChaptersProvider _localChaptersProvider; private readonly ILocalChaptersProvider _localChaptersProvider;
private readonly IScannerProxy _scannerProxy; private readonly IScannerProxy _scannerProxy;
private readonly ILocalFileSystem _localFileSystem; private readonly IFileSystem _fileSystem;
private readonly ILogger _logger; private readonly ILogger _logger;
private readonly IMetadataRepository _metadataRepository; private readonly IMetadataRepository _metadataRepository;
protected MediaServerTelevisionLibraryScanner( protected MediaServerTelevisionLibraryScanner(
IScannerProxy scannerProxy, IScannerProxy scannerProxy,
ILocalFileSystem localFileSystem, IFileSystem fileSystem,
ILocalChaptersProvider localChaptersProvider, ILocalChaptersProvider localChaptersProvider,
IMetadataRepository metadataRepository, IMetadataRepository metadataRepository,
ILogger logger) ILogger logger)
{ {
_scannerProxy = scannerProxy; _scannerProxy = scannerProxy;
_localFileSystem = localFileSystem; _fileSystem = fileSystem;
_localChaptersProvider = localChaptersProvider; _localChaptersProvider = localChaptersProvider;
_metadataRepository = metadataRepository; _metadataRepository = metadataRepository;
_logger = logger; _logger = logger;
@ -469,7 +469,7 @@ public abstract class MediaServerTelevisionLibraryScanner<TConnectionParameters,
{ {
await televisionRepository.SetEtag(result.Item, MediaServerEtag(incoming), cancellationToken); await televisionRepository.SetEtag(result.Item, MediaServerEtag(incoming), cancellationToken);
if (_localFileSystem.FileExists(result.LocalPath)) if (_fileSystem.File.Exists(result.LocalPath))
{ {
Option<int> flagResult = await televisionRepository.FlagNormal(library, result.Item, cancellationToken); Option<int> flagResult = await televisionRepository.FlagNormal(library, result.Item, cancellationToken);
if (flagResult.IsSome) if (flagResult.IsSome)
@ -541,7 +541,7 @@ public abstract class MediaServerTelevisionLibraryScanner<TConnectionParameters,
existingEtag == MediaServerEtag(incoming)) existingEtag == MediaServerEtag(incoming))
{ {
// skip scanning unavailable/file not found items that are unchanged and still don't exist locally // skip scanning unavailable/file not found items that are unchanged and still don't exist locally
if (!_localFileSystem.FileExists(localPath) && !ServerSupportsRemoteStreaming) if (!_fileSystem.File.Exists(localPath) && !ServerSupportsRemoteStreaming)
{ {
return false; return false;
} }
@ -550,7 +550,7 @@ public abstract class MediaServerTelevisionLibraryScanner<TConnectionParameters,
{ {
// item is unchanged, but file does not exist // item is unchanged, but file does not exist
// don't scan, but mark as unavailable // don't scan, but mark as unavailable
if (!_localFileSystem.FileExists(localPath)) if (!_fileSystem.File.Exists(localPath))
{ {
if (ServerSupportsRemoteStreaming) if (ServerSupportsRemoteStreaming)
{ {
@ -741,7 +741,7 @@ public abstract class MediaServerTelevisionLibraryScanner<TConnectionParameters,
if (deepScan || result.IsAdded || MediaServerEtag(existing) != MediaServerEtag(incoming) || if (deepScan || result.IsAdded || MediaServerEtag(existing) != MediaServerEtag(incoming) ||
existing.MediaVersions.Head().Streams.Count == 0) existing.MediaVersions.Head().Streams.Count == 0)
{ {
// if (maybeMediaVersion.IsNone && _localFileSystem.FileExists(result.LocalPath)) // if (maybeMediaVersion.IsNone && _fileSystem.File.Exists(result.LocalPath))
// { // {
// _logger.LogDebug("Refreshing {Attribute} for {Path}", "Statistics", result.LocalPath); // _logger.LogDebug("Refreshing {Attribute} for {Path}", "Statistics", result.LocalPath);
// Either<BaseError, bool> refreshResult = // Either<BaseError, bool> refreshResult =

14
ErsatzTV.Scanner/Core/Metadata/MovieFolderScanner.cs

@ -1,4 +1,5 @@
using System.Collections.Immutable; using System.Collections.Immutable;
using System.IO.Abstractions;
using Bugsnag; using Bugsnag;
using ErsatzTV.Core; using ErsatzTV.Core;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
@ -23,6 +24,7 @@ public class MovieFolderScanner : LocalFolderScanner, IMovieFolderScanner
private readonly ILibraryRepository _libraryRepository; private readonly ILibraryRepository _libraryRepository;
private readonly ILocalChaptersProvider _localChaptersProvider; private readonly ILocalChaptersProvider _localChaptersProvider;
private readonly IScannerProxy _scannerProxy; private readonly IScannerProxy _scannerProxy;
private readonly IFileSystem _fileSystem;
private readonly ILocalFileSystem _localFileSystem; private readonly ILocalFileSystem _localFileSystem;
private readonly ILocalMetadataProvider _localMetadataProvider; private readonly ILocalMetadataProvider _localMetadataProvider;
private readonly ILocalSubtitlesProvider _localSubtitlesProvider; private readonly ILocalSubtitlesProvider _localSubtitlesProvider;
@ -32,6 +34,7 @@ public class MovieFolderScanner : LocalFolderScanner, IMovieFolderScanner
public MovieFolderScanner( public MovieFolderScanner(
IScannerProxy scannerProxy, IScannerProxy scannerProxy,
IFileSystem fileSystem,
ILocalFileSystem localFileSystem, ILocalFileSystem localFileSystem,
IMovieRepository movieRepository, IMovieRepository movieRepository,
ILocalStatisticsProvider localStatisticsProvider, ILocalStatisticsProvider localStatisticsProvider,
@ -47,7 +50,7 @@ public class MovieFolderScanner : LocalFolderScanner, IMovieFolderScanner
IClient client, IClient client,
ILogger<MovieFolderScanner> logger) ILogger<MovieFolderScanner> logger)
: base( : base(
localFileSystem, fileSystem,
localStatisticsProvider, localStatisticsProvider,
metadataRepository, metadataRepository,
mediaItemRepository, mediaItemRepository,
@ -58,6 +61,7 @@ public class MovieFolderScanner : LocalFolderScanner, IMovieFolderScanner
logger) logger)
{ {
_scannerProxy = scannerProxy; _scannerProxy = scannerProxy;
_fileSystem = fileSystem;
_localFileSystem = localFileSystem; _localFileSystem = localFileSystem;
_movieRepository = movieRepository; _movieRepository = movieRepository;
_localSubtitlesProvider = localSubtitlesProvider; _localSubtitlesProvider = localSubtitlesProvider;
@ -209,7 +213,7 @@ public class MovieFolderScanner : LocalFolderScanner, IMovieFolderScanner
foreach (string path in await _movieRepository.FindMoviePaths(libraryPath)) foreach (string path in await _movieRepository.FindMoviePaths(libraryPath))
{ {
if (!_localFileSystem.FileExists(path)) if (!_fileSystem.File.Exists(path))
{ {
_logger.LogInformation("Flagging missing movie at {Path}", path); _logger.LogInformation("Flagging missing movie at {Path}", path);
List<int> ids = await FlagFileNotFound(libraryPath, path); List<int> ids = await FlagFileNotFound(libraryPath, path);
@ -362,7 +366,7 @@ public class MovieFolderScanner : LocalFolderScanner, IMovieFolderScanner
string movieAsNfo = Path.ChangeExtension(path, "nfo"); string movieAsNfo = Path.ChangeExtension(path, "nfo");
string movieNfo = Path.Combine(Path.GetDirectoryName(path) ?? string.Empty, "movie.nfo"); string movieNfo = Path.Combine(Path.GetDirectoryName(path) ?? string.Empty, "movie.nfo");
return Seq.create(movieAsNfo, movieNfo) return Seq.create(movieAsNfo, movieNfo)
.Filter(s => _localFileSystem.FileExists(s)) .Filter(s => _fileSystem.File.Exists(s))
.HeadOrNone(); .HeadOrNone();
} }
@ -380,12 +384,12 @@ public class MovieFolderScanner : LocalFolderScanner, IMovieFolderScanner
IEnumerable<string> possibleMoviePosters = ImageFileExtensions.Collect(ext => IEnumerable<string> possibleMoviePosters = ImageFileExtensions.Collect(ext =>
new[] { $"{segment}.{ext}", Path.GetFileNameWithoutExtension(path) + $"-{segment}.{ext}" }) new[] { $"{segment}.{ext}", Path.GetFileNameWithoutExtension(path) + $"-{segment}.{ext}" })
.Map(f => Path.Combine(folder, f)); .Map(f => Path.Combine(folder, f));
Option<string> result = possibleMoviePosters.Filter(p => _localFileSystem.FileExists(p)).HeadOrNone(); Option<string> result = possibleMoviePosters.Filter(p => _fileSystem.File.Exists(p)).HeadOrNone();
if (result.IsNone && artworkKind == ArtworkKind.Poster) if (result.IsNone && artworkKind == ArtworkKind.Poster)
{ {
IEnumerable<string> possibleFolderPosters = ImageFileExtensions.Collect(ext => new[] { $"folder.{ext}" }) IEnumerable<string> possibleFolderPosters = ImageFileExtensions.Collect(ext => new[] { $"folder.{ext}" })
.Map(f => Path.Combine(folder, f)); .Map(f => Path.Combine(folder, f));
result = possibleFolderPosters.Filter(p => _localFileSystem.FileExists(p)).HeadOrNone(); result = possibleFolderPosters.Filter(p => _fileSystem.File.Exists(p)).HeadOrNone();
} }
return result; return result;

16
ErsatzTV.Scanner/Core/Metadata/MusicVideoFolderScanner.cs

@ -1,4 +1,5 @@
using System.Collections.Immutable; using System.Collections.Immutable;
using System.IO.Abstractions;
using Bugsnag; using Bugsnag;
using ErsatzTV.Core; using ErsatzTV.Core;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
@ -23,6 +24,7 @@ public class MusicVideoFolderScanner : LocalFolderScanner, IMusicVideoFolderScan
private readonly ILibraryRepository _libraryRepository; private readonly ILibraryRepository _libraryRepository;
private readonly ILocalChaptersProvider _localChaptersProvider; private readonly ILocalChaptersProvider _localChaptersProvider;
private readonly IScannerProxy _scannerProxy; private readonly IScannerProxy _scannerProxy;
private readonly IFileSystem _fileSystem;
private readonly ILocalFileSystem _localFileSystem; private readonly ILocalFileSystem _localFileSystem;
private readonly ILocalMetadataProvider _localMetadataProvider; private readonly ILocalMetadataProvider _localMetadataProvider;
private readonly ILocalSubtitlesProvider _localSubtitlesProvider; private readonly ILocalSubtitlesProvider _localSubtitlesProvider;
@ -32,6 +34,7 @@ public class MusicVideoFolderScanner : LocalFolderScanner, IMusicVideoFolderScan
public MusicVideoFolderScanner( public MusicVideoFolderScanner(
IScannerProxy scannerProxy, IScannerProxy scannerProxy,
IFileSystem fileSystem,
ILocalFileSystem localFileSystem, ILocalFileSystem localFileSystem,
ILocalStatisticsProvider localStatisticsProvider, ILocalStatisticsProvider localStatisticsProvider,
ILocalMetadataProvider localMetadataProvider, ILocalMetadataProvider localMetadataProvider,
@ -47,7 +50,7 @@ public class MusicVideoFolderScanner : LocalFolderScanner, IMusicVideoFolderScan
ITempFilePool tempFilePool, ITempFilePool tempFilePool,
IClient client, IClient client,
ILogger<MusicVideoFolderScanner> logger) : base( ILogger<MusicVideoFolderScanner> logger) : base(
localFileSystem, fileSystem,
localStatisticsProvider, localStatisticsProvider,
metadataRepository, metadataRepository,
mediaItemRepository, mediaItemRepository,
@ -58,6 +61,7 @@ public class MusicVideoFolderScanner : LocalFolderScanner, IMusicVideoFolderScan
logger) logger)
{ {
_scannerProxy = scannerProxy; _scannerProxy = scannerProxy;
_fileSystem = fileSystem;
_localFileSystem = localFileSystem; _localFileSystem = localFileSystem;
_localMetadataProvider = localMetadataProvider; _localMetadataProvider = localMetadataProvider;
_localSubtitlesProvider = localSubtitlesProvider; _localSubtitlesProvider = localSubtitlesProvider;
@ -173,7 +177,7 @@ public class MusicVideoFolderScanner : LocalFolderScanner, IMusicVideoFolderScan
foreach (string path in await _musicVideoRepository.FindMusicVideoPaths(libraryPath)) foreach (string path in await _musicVideoRepository.FindMusicVideoPaths(libraryPath))
{ {
if (!_localFileSystem.FileExists(path)) if (!_fileSystem.File.Exists(path))
{ {
_logger.LogInformation("Flagging missing music video at {Path}", path); _logger.LogInformation("Flagging missing music video at {Path}", path);
List<int> musicVideoIds = await FlagFileNotFound(libraryPath, path); List<int> musicVideoIds = await FlagFileNotFound(libraryPath, path);
@ -459,7 +463,7 @@ public class MusicVideoFolderScanner : LocalFolderScanner, IMusicVideoFolderScan
} }
private Option<string> LocateNfoFileForArtist(string artistFolder) => private Option<string> LocateNfoFileForArtist(string artistFolder) =>
Optional(Path.Combine(artistFolder, "artist.nfo")).Filter(s => _localFileSystem.FileExists(s)); Optional(Path.Combine(artistFolder, "artist.nfo")).Filter(s => _fileSystem.File.Exists(s));
private Option<string> LocateArtworkForArtist(string artistFolder, ArtworkKind artworkKind) private Option<string> LocateArtworkForArtist(string artistFolder, ArtworkKind artworkKind)
{ {
@ -473,7 +477,7 @@ public class MusicVideoFolderScanner : LocalFolderScanner, IMusicVideoFolderScan
return ImageFileExtensions return ImageFileExtensions
.Map(ext => $"{segment}.{ext}") .Map(ext => $"{segment}.{ext}")
.Map(f => Path.Combine(artistFolder, f)) .Map(f => Path.Combine(artistFolder, f))
.Filter(s => _localFileSystem.FileExists(s)) .Filter(s => _fileSystem.File.Exists(s))
.HeadOrNone(); .HeadOrNone();
} }
@ -481,7 +485,7 @@ public class MusicVideoFolderScanner : LocalFolderScanner, IMusicVideoFolderScan
{ {
string path = musicVideo.MediaVersions.Head().MediaFiles.Head().Path; string path = musicVideo.MediaVersions.Head().MediaFiles.Head().Path;
return Optional(Path.ChangeExtension(path, "nfo")) return Optional(Path.ChangeExtension(path, "nfo"))
.Filter(s => _localFileSystem.FileExists(s)) .Filter(s => _fileSystem.File.Exists(s))
.HeadOrNone(); .HeadOrNone();
} }
@ -554,7 +558,7 @@ public class MusicVideoFolderScanner : LocalFolderScanner, IMusicVideoFolderScan
return ImageFileExtensions return ImageFileExtensions
.SelectMany(ext => new[] { Path.ChangeExtension(path, ext), Path.ChangeExtension(thumbPath, ext) }) .SelectMany(ext => new[] { Path.ChangeExtension(path, ext), Path.ChangeExtension(thumbPath, ext) })
.Filter(f => _localFileSystem.FileExists(f)) .Filter(f => _fileSystem.File.Exists(f))
.HeadOrNone(); .HeadOrNone();
} }
} }

12
ErsatzTV.Scanner/Core/Metadata/OtherVideoFolderScanner.cs

@ -1,4 +1,5 @@
using System.Collections.Immutable; using System.Collections.Immutable;
using System.IO.Abstractions;
using Bugsnag; using Bugsnag;
using ErsatzTV.Core; using ErsatzTV.Core;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
@ -22,6 +23,7 @@ public class OtherVideoFolderScanner : LocalFolderScanner, IOtherVideoFolderScan
private readonly ILibraryRepository _libraryRepository; private readonly ILibraryRepository _libraryRepository;
private readonly ILocalChaptersProvider _localChaptersProvider; private readonly ILocalChaptersProvider _localChaptersProvider;
private readonly IScannerProxy _scannerProxy; private readonly IScannerProxy _scannerProxy;
private readonly IFileSystem _fileSystem;
private readonly ILocalFileSystem _localFileSystem; private readonly ILocalFileSystem _localFileSystem;
private readonly ILocalMetadataProvider _localMetadataProvider; private readonly ILocalMetadataProvider _localMetadataProvider;
private readonly ILocalSubtitlesProvider _localSubtitlesProvider; private readonly ILocalSubtitlesProvider _localSubtitlesProvider;
@ -31,6 +33,7 @@ public class OtherVideoFolderScanner : LocalFolderScanner, IOtherVideoFolderScan
public OtherVideoFolderScanner( public OtherVideoFolderScanner(
IScannerProxy scannerProxy, IScannerProxy scannerProxy,
IFileSystem fileSystem,
ILocalFileSystem localFileSystem, ILocalFileSystem localFileSystem,
ILocalStatisticsProvider localStatisticsProvider, ILocalStatisticsProvider localStatisticsProvider,
ILocalMetadataProvider localMetadataProvider, ILocalMetadataProvider localMetadataProvider,
@ -45,7 +48,7 @@ public class OtherVideoFolderScanner : LocalFolderScanner, IOtherVideoFolderScan
ITempFilePool tempFilePool, ITempFilePool tempFilePool,
IClient client, IClient client,
ILogger<OtherVideoFolderScanner> logger) : base( ILogger<OtherVideoFolderScanner> logger) : base(
localFileSystem, fileSystem,
localStatisticsProvider, localStatisticsProvider,
metadataRepository, metadataRepository,
mediaItemRepository, mediaItemRepository,
@ -56,6 +59,7 @@ public class OtherVideoFolderScanner : LocalFolderScanner, IOtherVideoFolderScan
logger) logger)
{ {
_scannerProxy = scannerProxy; _scannerProxy = scannerProxy;
_fileSystem = fileSystem;
_localFileSystem = localFileSystem; _localFileSystem = localFileSystem;
_localMetadataProvider = localMetadataProvider; _localMetadataProvider = localMetadataProvider;
_localSubtitlesProvider = localSubtitlesProvider; _localSubtitlesProvider = localSubtitlesProvider;
@ -220,7 +224,7 @@ public class OtherVideoFolderScanner : LocalFolderScanner, IOtherVideoFolderScan
foreach (string path in await _otherVideoRepository.FindOtherVideoPaths(libraryPath)) foreach (string path in await _otherVideoRepository.FindOtherVideoPaths(libraryPath))
{ {
if (!_localFileSystem.FileExists(path)) if (!_fileSystem.File.Exists(path))
{ {
_logger.LogInformation("Flagging missing other video at {Path}", path); _logger.LogInformation("Flagging missing other video at {Path}", path);
List<int> otherVideoIds = await FlagFileNotFound(libraryPath, path); List<int> otherVideoIds = await FlagFileNotFound(libraryPath, path);
@ -274,7 +278,7 @@ public class OtherVideoFolderScanner : LocalFolderScanner, IOtherVideoFolderScan
string path = otherVideo.MediaVersions.Head().MediaFiles.Head().Path; string path = otherVideo.MediaVersions.Head().MediaFiles.Head().Path;
Option<string> maybeNfoFile = new List<string> { Path.ChangeExtension(path, "nfo") } Option<string> maybeNfoFile = new List<string> { Path.ChangeExtension(path, "nfo") }
.Filter(_localFileSystem.FileExists) .Filter(_fileSystem.File.Exists)
.HeadOrNone(); .HeadOrNone();
if (maybeNfoFile.IsNone) if (maybeNfoFile.IsNone)
@ -376,7 +380,7 @@ public class OtherVideoFolderScanner : LocalFolderScanner, IOtherVideoFolderScan
string path = otherVideo.MediaVersions.Head().MediaFiles.Head().Path; string path = otherVideo.MediaVersions.Head().MediaFiles.Head().Path;
return ImageFileExtensions return ImageFileExtensions
.Map(ext => Path.ChangeExtension(path, ext)) .Map(ext => Path.ChangeExtension(path, ext))
.Filter(f => _localFileSystem.FileExists(f)) .Filter(f => _fileSystem.File.Exists(f))
.HeadOrNone(); .HeadOrNone();
} }
} }

8
ErsatzTV.Scanner/Core/Metadata/RemoteStreamFolderScanner.cs

@ -1,4 +1,5 @@
using System.Collections.Immutable; using System.Collections.Immutable;
using System.IO.Abstractions;
using Bugsnag; using Bugsnag;
using ErsatzTV.Core; using ErsatzTV.Core;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
@ -24,6 +25,7 @@ public class RemoteStreamFolderScanner : LocalFolderScanner, IRemoteStreamFolder
private readonly IClient _client; private readonly IClient _client;
private readonly ILibraryRepository _libraryRepository; private readonly ILibraryRepository _libraryRepository;
private readonly IScannerProxy _scannerProxy; private readonly IScannerProxy _scannerProxy;
private readonly IFileSystem _fileSystem;
private readonly ILocalFileSystem _localFileSystem; private readonly ILocalFileSystem _localFileSystem;
private readonly ILocalMetadataProvider _localMetadataProvider; private readonly ILocalMetadataProvider _localMetadataProvider;
private readonly ILogger<RemoteStreamFolderScanner> _logger; private readonly ILogger<RemoteStreamFolderScanner> _logger;
@ -32,6 +34,7 @@ public class RemoteStreamFolderScanner : LocalFolderScanner, IRemoteStreamFolder
public RemoteStreamFolderScanner( public RemoteStreamFolderScanner(
IScannerProxy scannerProxy, IScannerProxy scannerProxy,
IFileSystem fileSystem,
ILocalFileSystem localFileSystem, ILocalFileSystem localFileSystem,
ILocalStatisticsProvider localStatisticsProvider, ILocalStatisticsProvider localStatisticsProvider,
ILocalMetadataProvider localMetadataProvider, ILocalMetadataProvider localMetadataProvider,
@ -44,7 +47,7 @@ public class RemoteStreamFolderScanner : LocalFolderScanner, IRemoteStreamFolder
ITempFilePool tempFilePool, ITempFilePool tempFilePool,
IClient client, IClient client,
ILogger<RemoteStreamFolderScanner> logger) : base( ILogger<RemoteStreamFolderScanner> logger) : base(
localFileSystem, fileSystem,
localStatisticsProvider, localStatisticsProvider,
metadataRepository, metadataRepository,
mediaItemRepository, mediaItemRepository,
@ -55,6 +58,7 @@ public class RemoteStreamFolderScanner : LocalFolderScanner, IRemoteStreamFolder
logger) logger)
{ {
_scannerProxy = scannerProxy; _scannerProxy = scannerProxy;
_fileSystem = fileSystem;
_localFileSystem = localFileSystem; _localFileSystem = localFileSystem;
_localMetadataProvider = localMetadataProvider; _localMetadataProvider = localMetadataProvider;
_remoteStreamRepository = remoteStreamRepository; _remoteStreamRepository = remoteStreamRepository;
@ -213,7 +217,7 @@ public class RemoteStreamFolderScanner : LocalFolderScanner, IRemoteStreamFolder
foreach (string path in await _remoteStreamRepository.FindRemoteStreamPaths(libraryPath, cancellationToken)) foreach (string path in await _remoteStreamRepository.FindRemoteStreamPaths(libraryPath, cancellationToken))
{ {
if (!_localFileSystem.FileExists(path)) if (!_fileSystem.File.Exists(path))
{ {
_logger.LogInformation("Flagging missing remote stream at {Path}", path); _logger.LogInformation("Flagging missing remote stream at {Path}", path);
List<int> remoteStreamIds = await FlagFileNotFound(libraryPath, path); List<int> remoteStreamIds = await FlagFileNotFound(libraryPath, path);

10
ErsatzTV.Scanner/Core/Metadata/SongFolderScanner.cs

@ -1,4 +1,5 @@
using System.Collections.Immutable; using System.Collections.Immutable;
using System.IO.Abstractions;
using Bugsnag; using Bugsnag;
using ErsatzTV.Core; using ErsatzTV.Core;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
@ -21,6 +22,7 @@ public class SongFolderScanner : LocalFolderScanner, ISongFolderScanner
private readonly IClient _client; private readonly IClient _client;
private readonly ILibraryRepository _libraryRepository; private readonly ILibraryRepository _libraryRepository;
private readonly IScannerProxy _scannerProxy; private readonly IScannerProxy _scannerProxy;
private readonly IFileSystem _fileSystem;
private readonly ILocalFileSystem _localFileSystem; private readonly ILocalFileSystem _localFileSystem;
private readonly ILocalMetadataProvider _localMetadataProvider; private readonly ILocalMetadataProvider _localMetadataProvider;
private readonly ILogger<SongFolderScanner> _logger; private readonly ILogger<SongFolderScanner> _logger;
@ -29,6 +31,7 @@ public class SongFolderScanner : LocalFolderScanner, ISongFolderScanner
public SongFolderScanner( public SongFolderScanner(
IScannerProxy scannerProxy, IScannerProxy scannerProxy,
IFileSystem fileSystem,
ILocalFileSystem localFileSystem, ILocalFileSystem localFileSystem,
ILocalStatisticsProvider localStatisticsProvider, ILocalStatisticsProvider localStatisticsProvider,
ILocalMetadataProvider localMetadataProvider, ILocalMetadataProvider localMetadataProvider,
@ -41,7 +44,7 @@ public class SongFolderScanner : LocalFolderScanner, ISongFolderScanner
ITempFilePool tempFilePool, ITempFilePool tempFilePool,
IClient client, IClient client,
ILogger<SongFolderScanner> logger) : base( ILogger<SongFolderScanner> logger) : base(
localFileSystem, fileSystem,
localStatisticsProvider, localStatisticsProvider,
metadataRepository, metadataRepository,
mediaItemRepository, mediaItemRepository,
@ -52,6 +55,7 @@ public class SongFolderScanner : LocalFolderScanner, ISongFolderScanner
logger) logger)
{ {
_scannerProxy = scannerProxy; _scannerProxy = scannerProxy;
_fileSystem = fileSystem;
_localFileSystem = localFileSystem; _localFileSystem = localFileSystem;
_localMetadataProvider = localMetadataProvider; _localMetadataProvider = localMetadataProvider;
_songRepository = songRepository; _songRepository = songRepository;
@ -201,7 +205,7 @@ public class SongFolderScanner : LocalFolderScanner, ISongFolderScanner
foreach (string path in await _songRepository.FindSongPaths(libraryPath)) foreach (string path in await _songRepository.FindSongPaths(libraryPath))
{ {
if (!_localFileSystem.FileExists(path)) if (!_fileSystem.File.Exists(path))
{ {
_logger.LogInformation("Flagging missing song at {Path}", path); _logger.LogInformation("Flagging missing song at {Path}", path);
List<int> songIds = await FlagFileNotFound(libraryPath, path); List<int> songIds = await FlagFileNotFound(libraryPath, path);
@ -336,7 +340,7 @@ public class SongFolderScanner : LocalFolderScanner, ISongFolderScanner
string coverPath = Path.Combine(di.FullName, "cover.jpg"); string coverPath = Path.Combine(di.FullName, "cover.jpg");
return ImageFileExtensions return ImageFileExtensions
.Map(ext => Path.ChangeExtension(coverPath, ext)) .Map(ext => Path.ChangeExtension(coverPath, ext))
.Filter(f => _localFileSystem.FileExists(f)) .Filter(f => _fileSystem.File.Exists(f))
.HeadOrNone(); .HeadOrNone();
}).Flatten(); }).Flatten();
} }

18
ErsatzTV.Scanner/Core/Metadata/TelevisionFolderScanner.cs

@ -1,4 +1,5 @@
using System.Collections.Immutable; using System.Collections.Immutable;
using System.IO.Abstractions;
using Bugsnag; using Bugsnag;
using ErsatzTV.Core; using ErsatzTV.Core;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
@ -23,6 +24,7 @@ public class TelevisionFolderScanner : LocalFolderScanner, ITelevisionFolderScan
private readonly ILibraryRepository _libraryRepository; private readonly ILibraryRepository _libraryRepository;
private readonly ILocalChaptersProvider _localChaptersProvider; private readonly ILocalChaptersProvider _localChaptersProvider;
private readonly IScannerProxy _scannerProxy; private readonly IScannerProxy _scannerProxy;
private readonly IFileSystem _fileSystem;
private readonly ILocalFileSystem _localFileSystem; private readonly ILocalFileSystem _localFileSystem;
private readonly ILocalMetadataProvider _localMetadataProvider; private readonly ILocalMetadataProvider _localMetadataProvider;
private readonly ILocalSubtitlesProvider _localSubtitlesProvider; private readonly ILocalSubtitlesProvider _localSubtitlesProvider;
@ -33,6 +35,7 @@ public class TelevisionFolderScanner : LocalFolderScanner, ITelevisionFolderScan
public TelevisionFolderScanner( public TelevisionFolderScanner(
IScannerProxy scannerProxy, IScannerProxy scannerProxy,
IFileSystem fileSystem,
ILocalFileSystem localFileSystem, ILocalFileSystem localFileSystem,
ITelevisionRepository televisionRepository, ITelevisionRepository televisionRepository,
ILocalStatisticsProvider localStatisticsProvider, ILocalStatisticsProvider localStatisticsProvider,
@ -48,7 +51,7 @@ public class TelevisionFolderScanner : LocalFolderScanner, ITelevisionFolderScan
IClient client, IClient client,
IFallbackMetadataProvider fallbackMetadataProvider, IFallbackMetadataProvider fallbackMetadataProvider,
ILogger<TelevisionFolderScanner> logger) : base( ILogger<TelevisionFolderScanner> logger) : base(
localFileSystem, fileSystem,
localStatisticsProvider, localStatisticsProvider,
metadataRepository, metadataRepository,
mediaItemRepository, mediaItemRepository,
@ -59,6 +62,7 @@ public class TelevisionFolderScanner : LocalFolderScanner, ITelevisionFolderScan
logger) logger)
{ {
_scannerProxy = scannerProxy; _scannerProxy = scannerProxy;
_fileSystem = fileSystem;
_localFileSystem = localFileSystem; _localFileSystem = localFileSystem;
_televisionRepository = televisionRepository; _televisionRepository = televisionRepository;
_localMetadataProvider = localMetadataProvider; _localMetadataProvider = localMetadataProvider;
@ -169,7 +173,7 @@ public class TelevisionFolderScanner : LocalFolderScanner, ITelevisionFolderScan
foreach (string path in await _televisionRepository.FindEpisodePaths(libraryPath)) foreach (string path in await _televisionRepository.FindEpisodePaths(libraryPath))
{ {
if (!_localFileSystem.FileExists(path)) if (!_fileSystem.File.Exists(path))
{ {
_logger.LogInformation("Flagging missing episode at {Path}", path); _logger.LogInformation("Flagging missing episode at {Path}", path);
@ -584,12 +588,12 @@ public class TelevisionFolderScanner : LocalFolderScanner, ITelevisionFolderScan
} }
private Option<string> LocateNfoFileForShow(string showFolder) => private Option<string> LocateNfoFileForShow(string showFolder) =>
Optional(Path.Combine(showFolder, "tvshow.nfo")).Filter(s => _localFileSystem.FileExists(s)); Optional(Path.Combine(showFolder, "tvshow.nfo")).Filter(s => _fileSystem.File.Exists(s));
private Option<string> LocateNfoFile(Episode episode) private Option<string> LocateNfoFile(Episode episode)
{ {
string path = episode.MediaVersions.Head().MediaFiles.Head().Path; string path = episode.MediaVersions.Head().MediaFiles.Head().Path;
return Optional(Path.ChangeExtension(path, "nfo")).Filter(s => _localFileSystem.FileExists(s)); return Optional(Path.ChangeExtension(path, "nfo")).Filter(s => _fileSystem.File.Exists(s));
} }
private Option<string> LocateArtworkForShow(string showFolder, ArtworkKind artworkKind) private Option<string> LocateArtworkForShow(string showFolder, ArtworkKind artworkKind)
@ -606,7 +610,7 @@ public class TelevisionFolderScanner : LocalFolderScanner, ITelevisionFolderScan
.Map(ext => segments.Map(segment => $"{segment}.{ext}")) .Map(ext => segments.Map(segment => $"{segment}.{ext}"))
.Flatten() .Flatten()
.Map(f => Path.Combine(showFolder, f)) .Map(f => Path.Combine(showFolder, f))
.Filter(s => _localFileSystem.FileExists(s)) .Filter(s => _fileSystem.File.Exists(s))
.HeadOrNone(); .HeadOrNone();
} }
@ -615,7 +619,7 @@ public class TelevisionFolderScanner : LocalFolderScanner, ITelevisionFolderScan
string folder = Path.GetDirectoryName(seasonFolder) ?? string.Empty; string folder = Path.GetDirectoryName(seasonFolder) ?? string.Empty;
return ImageFileExtensions return ImageFileExtensions
.Map(ext => Path.Combine(folder, $"season{season.SeasonNumber:00}-poster.{ext}")) .Map(ext => Path.Combine(folder, $"season{season.SeasonNumber:00}-poster.{ext}"))
.Filter(s => _localFileSystem.FileExists(s)) .Filter(s => _fileSystem.File.Exists(s))
.HeadOrNone(); .HeadOrNone();
} }
@ -626,7 +630,7 @@ public class TelevisionFolderScanner : LocalFolderScanner, ITelevisionFolderScan
return ImageFileExtensions return ImageFileExtensions
.Map(ext => Path.GetFileNameWithoutExtension(path) + $"-thumb.{ext}") .Map(ext => Path.GetFileNameWithoutExtension(path) + $"-thumb.{ext}")
.Map(f => Path.Combine(folder, f)) .Map(f => Path.Combine(folder, f))
.Filter(f => _localFileSystem.FileExists(f)) .Filter(f => _fileSystem.File.Exists(f))
.HeadOrNone(); .HeadOrNone();
} }
} }

8
ErsatzTV.Scanner/Core/Plex/PlexMovieLibraryScanner.cs

@ -1,7 +1,7 @@
using ErsatzTV.Core; using System.IO.Abstractions;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Extensions; using ErsatzTV.Core.Extensions;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Plex; using ErsatzTV.Core.Interfaces.Plex;
using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Metadata; using ErsatzTV.Core.Metadata;
@ -33,12 +33,12 @@ public class PlexMovieLibraryScanner :
IMediaSourceRepository mediaSourceRepository, IMediaSourceRepository mediaSourceRepository,
IPlexMovieRepository plexMovieRepository, IPlexMovieRepository plexMovieRepository,
IPlexPathReplacementService plexPathReplacementService, IPlexPathReplacementService plexPathReplacementService,
ILocalFileSystem localFileSystem, IFileSystem fileSystem,
ILocalChaptersProvider localChaptersProvider, ILocalChaptersProvider localChaptersProvider,
ILogger<PlexMovieLibraryScanner> logger) ILogger<PlexMovieLibraryScanner> logger)
: base( : base(
scannerProxy, scannerProxy,
localFileSystem, fileSystem,
localChaptersProvider, localChaptersProvider,
metadataRepository, metadataRepository,
logger) logger)

6
ErsatzTV.Scanner/Core/Plex/PlexOtherVideoLibraryScanner.cs

@ -1,7 +1,7 @@
using System.IO.Abstractions;
using ErsatzTV.Core; using ErsatzTV.Core;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Extensions; using ErsatzTV.Core.Extensions;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Plex; using ErsatzTV.Core.Interfaces.Plex;
using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Metadata; using ErsatzTV.Core.Metadata;
@ -33,12 +33,12 @@ public class PlexOtherVideoLibraryScanner :
IMediaSourceRepository mediaSourceRepository, IMediaSourceRepository mediaSourceRepository,
IPlexOtherVideoRepository plexOtherVideoRepository, IPlexOtherVideoRepository plexOtherVideoRepository,
IPlexPathReplacementService plexPathReplacementService, IPlexPathReplacementService plexPathReplacementService,
ILocalFileSystem localFileSystem, IFileSystem fileSystem,
ILocalChaptersProvider localChaptersProvider, ILocalChaptersProvider localChaptersProvider,
ILogger<PlexOtherVideoLibraryScanner> logger) ILogger<PlexOtherVideoLibraryScanner> logger)
: base( : base(
scannerProxy, scannerProxy,
localFileSystem, fileSystem,
localChaptersProvider, localChaptersProvider,
metadataRepository, metadataRepository,
logger) logger)

8
ErsatzTV.Scanner/Core/Plex/PlexTelevisionLibraryScanner.cs

@ -1,9 +1,9 @@
using System.Text.RegularExpressions; using System.IO.Abstractions;
using System.Text.RegularExpressions;
using ErsatzTV.Core; using ErsatzTV.Core;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Errors; using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Extensions; using ErsatzTV.Core.Extensions;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Plex; using ErsatzTV.Core.Interfaces.Plex;
using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Metadata; using ErsatzTV.Core.Metadata;
@ -37,12 +37,12 @@ public partial class PlexTelevisionLibraryScanner :
IMediaSourceRepository mediaSourceRepository, IMediaSourceRepository mediaSourceRepository,
IPlexPathReplacementService plexPathReplacementService, IPlexPathReplacementService plexPathReplacementService,
IPlexTelevisionRepository plexTelevisionRepository, IPlexTelevisionRepository plexTelevisionRepository,
ILocalFileSystem localFileSystem, IFileSystem fileSystem,
ILocalChaptersProvider localChaptersProvider, ILocalChaptersProvider localChaptersProvider,
ILogger<PlexTelevisionLibraryScanner> logger) ILogger<PlexTelevisionLibraryScanner> logger)
: base( : base(
scannerProxy, scannerProxy,
localFileSystem, fileSystem,
localChaptersProvider, localChaptersProvider,
metadataRepository, metadataRepository,
logger) logger)

4
ErsatzTV.Scanner/Program.cs

@ -1,3 +1,4 @@
using System.IO.Abstractions;
using Bugsnag; using Bugsnag;
using Bugsnag.Payload; using Bugsnag.Payload;
using Dapper; using Dapper;
@ -47,6 +48,7 @@ using Microsoft.IO;
using Serilog; using Serilog;
using Serilog.Events; using Serilog.Events;
using Serilog.Formatting.Compact; using Serilog.Formatting.Compact;
using Testably.Abstractions;
using Exception = System.Exception; using Exception = System.Exception;
using IConfiguration = Bugsnag.IConfiguration; using IConfiguration = Bugsnag.IConfiguration;
@ -252,6 +254,8 @@ public class Program
services.AddSingleton<IScannerProxy, ScannerProxy>(); services.AddSingleton<IScannerProxy, ScannerProxy>();
services.AddSingleton<ILanguageCodeCache, LanguageCodeCache>(); services.AddSingleton<ILanguageCodeCache, LanguageCodeCache>();
services.AddSingleton<IFileSystem, RealFileSystem>();
services.AddMediatR(config => config.RegisterServicesFromAssemblyContaining<Worker>()); services.AddMediatR(config => config.RegisterServicesFromAssemblyContaining<Worker>());
services.AddMemoryCache(); services.AddMemoryCache();

6
ErsatzTV/Controllers/Api/TroubleshootController.cs

@ -1,3 +1,4 @@
using System.IO.Abstractions;
using System.Threading.Channels; using System.Threading.Channels;
using ErsatzTV.Application; using ErsatzTV.Application;
using ErsatzTV.Application.MediaItems; using ErsatzTV.Application.MediaItems;
@ -6,7 +7,6 @@ using ErsatzTV.Application.Troubleshooting.Queries;
using ErsatzTV.Core; using ErsatzTV.Core;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.FFmpeg; using ErsatzTV.Core.Interfaces.FFmpeg;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Troubleshooting; using ErsatzTV.Core.Interfaces.Troubleshooting;
using MediatR; using MediatR;
@ -18,7 +18,7 @@ namespace ErsatzTV.Controllers.Api;
[ApiController] [ApiController]
public class TroubleshootController( public class TroubleshootController(
ChannelWriter<IFFmpegWorkerRequest> channelWriter, ChannelWriter<IFFmpegWorkerRequest> channelWriter,
ILocalFileSystem localFileSystem, IFileSystem fileSystem,
IConfigElementRepository configElementRepository, IConfigElementRepository configElementRepository,
ITroubleshootingNotifier notifier, ITroubleshootingNotifier notifier,
IMediator mediator) : ControllerBase IMediator mediator) : ControllerBase
@ -104,7 +104,7 @@ public class TroubleshootController(
cancellationToken); cancellationToken);
string playlistFile = Path.Combine(FileSystemLayout.TranscodeTroubleshootingFolder, "live.m3u8"); string playlistFile = Path.Combine(FileSystemLayout.TranscodeTroubleshootingFolder, "live.m3u8");
while (!localFileSystem.FileExists(playlistFile)) while (!fileSystem.File.Exists(playlistFile))
{ {
await Task.Delay(TimeSpan.FromMilliseconds(100), cancellationToken); await Task.Delay(TimeSpan.FromMilliseconds(100), cancellationToken);
if (cancellationToken.IsCancellationRequested || notifier.IsFailed(sessionId)) if (cancellationToken.IsCancellationRequested || notifier.IsFailed(sessionId))

6
ErsatzTV/Pages/Troubleshooting/PlaybackTroubleshooting.razor

@ -1,5 +1,6 @@
@page "/system/troubleshooting/playback" @page "/system/troubleshooting/playback"
@using System.Globalization @using System.Globalization
@using System.IO.Abstractions
@using ErsatzTV.Application.Channels @using ErsatzTV.Application.Channels
@using ErsatzTV.Application.FFmpegProfiles @using ErsatzTV.Application.FFmpegProfiles
@using ErsatzTV.Application.Graphics @using ErsatzTV.Application.Graphics
@ -7,7 +8,6 @@
@using ErsatzTV.Application.Troubleshooting @using ErsatzTV.Application.Troubleshooting
@using ErsatzTV.Application.Troubleshooting.Queries @using ErsatzTV.Application.Troubleshooting.Queries
@using ErsatzTV.Application.Watermarks @using ErsatzTV.Application.Watermarks
@using ErsatzTV.Core.Interfaces.Metadata
@using ErsatzTV.Core.Notifications @using ErsatzTV.Core.Notifications
@using MediatR.Courier @using MediatR.Courier
@using Microsoft.AspNetCore.WebUtilities @using Microsoft.AspNetCore.WebUtilities
@ -18,7 +18,7 @@
@inject IEntityLocker Locker @inject IEntityLocker Locker
@inject ICourier Courier; @inject ICourier Courier;
@inject ISnackbar Snackbar; @inject ISnackbar Snackbar;
@inject ILocalFileSystem LocalFileSystem; @inject IFileSystem FileSystem;
<MudForm Style="max-height: 100%"> <MudForm Style="max-height: 100%">
<MudPaper Square="true" Style="display: flex; height: 64px; min-height: 64px; width: 100%; z-index: 100; align-items: center"> <MudPaper Square="true" Style="display: flex; height: 64px; min-height: 64px; width: 100%; z-index: 100; align-items: center">
@ -422,7 +422,7 @@
} }
string logFileName = Path.Combine(FileSystemLayout.TranscodeTroubleshootingFolder, "logs.txt"); string logFileName = Path.Combine(FileSystemLayout.TranscodeTroubleshootingFolder, "logs.txt");
if (LocalFileSystem.FileExists(logFileName)) if (FileSystem.File.Exists(logFileName))
{ {
string text = await File.ReadAllTextAsync(logFileName); string text = await File.ReadAllTextAsync(logFileName);
await InvokeAsync(async () => { await _logsField.SetText(text); }); await InvokeAsync(async () => { await _logsField.SetText(text); });

46
ErsatzTV/Services/RunOnce/CacheCleanerService.cs

@ -1,4 +1,5 @@
using ErsatzTV.Core; using System.IO.Abstractions;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Metadata; using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Infrastructure.Data; using ErsatzTV.Infrastructure.Data;
@ -6,39 +7,30 @@ using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Services.RunOnce; namespace ErsatzTV.Services.RunOnce;
public class CacheCleanerService : BackgroundService public class CacheCleanerService(
IServiceScopeFactory serviceScopeFactory,
SystemStartup systemStartup,
ILogger<CacheCleanerService> logger)
: BackgroundService
{ {
private readonly ILogger<CacheCleanerService> _logger;
private readonly IServiceScopeFactory _serviceScopeFactory;
private readonly SystemStartup _systemStartup;
public CacheCleanerService(
IServiceScopeFactory serviceScopeFactory,
SystemStartup systemStartup,
ILogger<CacheCleanerService> logger)
{
_serviceScopeFactory = serviceScopeFactory;
_systemStartup = systemStartup;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken) protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{ {
await Task.Yield(); await Task.Yield();
await _systemStartup.WaitForDatabase(stoppingToken); await systemStartup.WaitForDatabase(stoppingToken);
if (stoppingToken.IsCancellationRequested) if (stoppingToken.IsCancellationRequested)
{ {
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>();
IFileSystem fileSystem = scope.ServiceProvider.GetRequiredService<IFileSystem>();
if (localFileSystem.FolderExists(FileSystemLayout.LegacyImageCacheFolder)) if (fileSystem.Directory.Exists(FileSystemLayout.LegacyImageCacheFolder))
{ {
_logger.LogInformation("Migrating channel logos from legacy image cache folder"); logger.LogInformation("Migrating channel logos from legacy image cache folder");
List<string> logos = await dbContext.Channels List<string> logos = await dbContext.Channels
.AsNoTracking() .AsNoTracking()
@ -50,7 +42,7 @@ public class CacheCleanerService : BackgroundService
foreach (string logo in logos) foreach (string logo in logos)
{ {
string legacyPath = Path.Combine(FileSystemLayout.LegacyImageCacheFolder, logo); string legacyPath = Path.Combine(FileSystemLayout.LegacyImageCacheFolder, logo);
if (localFileSystem.FileExists(legacyPath)) if (fileSystem.File.Exists(legacyPath))
{ {
string subfolder = logo[..2]; string subfolder = logo[..2];
string newPath = Path.Combine(FileSystemLayout.LogoCacheFolder, subfolder, logo); string newPath = Path.Combine(FileSystemLayout.LogoCacheFolder, subfolder, logo);
@ -58,20 +50,20 @@ public class CacheCleanerService : BackgroundService
} }
} }
_logger.LogInformation("Deleting legacy image cache folder"); logger.LogInformation("Deleting legacy image cache folder");
Directory.Delete(FileSystemLayout.LegacyImageCacheFolder, true); Directory.Delete(FileSystemLayout.LegacyImageCacheFolder, true);
} }
if (localFileSystem.FolderExists(FileSystemLayout.TranscodeFolder)) if (fileSystem.Directory.Exists(FileSystemLayout.TranscodeFolder))
{ {
_logger.LogInformation("Emptying transcode cache folder"); logger.LogInformation("Emptying transcode cache folder");
localFileSystem.EmptyFolder(FileSystemLayout.TranscodeFolder); localFileSystem.EmptyFolder(FileSystemLayout.TranscodeFolder);
_logger.LogInformation("Done emptying transcode cache folder"); logger.LogInformation("Done emptying transcode cache folder");
} }
if (localFileSystem.FolderExists(FileSystemLayout.ChannelGuideCacheFolder)) if (fileSystem.Directory.Exists(FileSystemLayout.ChannelGuideCacheFolder))
{ {
_logger.LogInformation("Cleaning channel cache"); logger.LogInformation("Cleaning channel cache");
List<string> channelFiles = await dbContext.Channels List<string> channelFiles = await dbContext.Channels
.AsNoTracking() .AsNoTracking()

4
ErsatzTV/Startup.cs

@ -1,5 +1,6 @@
using System.Diagnostics.CodeAnalysis; using System.Diagnostics.CodeAnalysis;
using System.Globalization; using System.Globalization;
using System.IO.Abstractions;
using System.Reflection; using System.Reflection;
using System.Runtime.InteropServices; using System.Runtime.InteropServices;
using System.Text; using System.Text;
@ -99,6 +100,7 @@ using Refit;
using Scalar.AspNetCore; using Scalar.AspNetCore;
using Serilog; using Serilog;
using Serilog.Events; using Serilog.Events;
using Testably.Abstractions;
namespace ErsatzTV; namespace ErsatzTV;
@ -692,6 +694,8 @@ public class Startup
{ {
services.AddSingleton<IEnvironmentValidator, EnvironmentValidator>(); services.AddSingleton<IEnvironmentValidator, EnvironmentValidator>();
services.AddSingleton<IFileSystem, RealFileSystem>();
services.AddSingleton<IDatabaseMigrations, DatabaseMigrations>(); services.AddSingleton<IDatabaseMigrations, DatabaseMigrations>();
services.AddSingleton<IPlexSecretStore, PlexSecretStore>(); services.AddSingleton<IPlexSecretStore, PlexSecretStore>();
services.AddSingleton<IPlexTvApiClient, PlexTvApiClient>(); // TODO: does this need to be singleton? services.AddSingleton<IPlexTvApiClient, PlexTvApiClient>(); // TODO: does this need to be singleton?

Loading…
Cancel
Save