Browse Source

implement most scanning actions

pull/25/head
Jason Dove 6 years ago
parent
commit
bea07cb992
  1. 9
      ErsatzTV.Application/MediaItems/Commands/CreateMediaItemHandler.cs
  2. 5
      ErsatzTV.Application/MediaItems/Commands/RefreshMediaItemMetadataHandler.cs
  3. 288
      ErsatzTV.Core.Tests/Metadata/LocalMediaSourcePlannerTests.cs
  4. 14
      ErsatzTV.Core/Interfaces/Metadata/ILocalMediaSourcePlanner.cs
  5. 3
      ErsatzTV.Core/Interfaces/Metadata/ILocalMetadataProvider.cs
  6. 4
      ErsatzTV.Core/Metadata/ActionPlan.cs
  7. 4
      ErsatzTV.Core/Metadata/ItemScanningPlan.cs
  8. 8
      ErsatzTV.Core/Metadata/LocalMediaItemScanningPlan.cs
  9. 283
      ErsatzTV.Core/Metadata/LocalMediaScanner.cs
  10. 11
      ErsatzTV.Core/Metadata/LocalMediaSourcePlan.cs
  11. 38
      ErsatzTV.Core/Metadata/LocalMediaSourcePlanner.cs
  12. 17
      ErsatzTV.Core/Metadata/LocalMetadataProvider.cs
  13. 2
      ErsatzTV.Core/Metadata/LocalPosterProvider.cs
  14. 13
      ErsatzTV.Core/Metadata/ScanningAction.cs
  15. 2
      ErsatzTV/Services/WorkerService.cs

9
ErsatzTV.Application/MediaItems/Commands/CreateMediaItemHandler.cs

@ -50,11 +50,12 @@ namespace ErsatzTV.Application.MediaItems.Commands
private async Task<MediaItemViewModel> PersistMediaItem(RequestParameters parameters) private async Task<MediaItemViewModel> PersistMediaItem(RequestParameters parameters)
{ {
await _mediaItemRepository.Add(parameters.MediaItem); await _mediaItemRepository.Add(parameters.MediaItem);
await _localStatisticsProvider.RefreshStatistics(parameters.FFprobePath, parameters.MediaItem); await _localStatisticsProvider.RefreshStatistics(parameters.FFprobePath, parameters.MediaItem);
await _localMetadataProvider.RefreshMetadata(parameters.MediaItem); // TODO: reimplement this
await _localPosterProvider.RefreshPoster(parameters.MediaItem); // await _localMetadataProvider.RefreshMetadata(parameters.MediaItem);
await _smartCollectionBuilder.RefreshSmartCollections(parameters.MediaItem); // await _localPosterProvider.RefreshPoster(parameters.MediaItem);
// await _smartCollectionBuilder.RefreshSmartCollections(parameters.MediaItem);
return ProjectToViewModel(parameters.MediaItem); return ProjectToViewModel(parameters.MediaItem);
} }

5
ErsatzTV.Application/MediaItems/Commands/RefreshMediaItemMetadataHandler.cs

@ -46,7 +46,8 @@ namespace ErsatzTV.Application.MediaItems.Commands
.Filter(item => File.Exists(item.Path)) .Filter(item => File.Exists(item.Path))
.ToValidation<BaseError>($"[Path] '{mediaItem.Path}' does not exist on the file system"); .ToValidation<BaseError>($"[Path] '{mediaItem.Path}' does not exist on the file system");
private Task<Unit> RefreshMetadata(MediaItem mediaItem) => private Task<Unit> RefreshMetadata(MediaItem mediaItem) => Task.CompletedTask.ToUnit();
_localMetadataProvider.RefreshMetadata(mediaItem).ToUnit(); // TODO: reimplement this
// _localMetadataProvider.RefreshMetadata(mediaItem).ToUnit();
} }
} }

288
ErsatzTV.Core.Tests/Metadata/TestMediaScannerTests.cs → ErsatzTV.Core.Tests/Metadata/LocalMediaSourcePlannerTests.cs

@ -11,7 +11,7 @@ using NUnit.Framework;
namespace ErsatzTV.Core.Tests.Metadata namespace ErsatzTV.Core.Tests.Metadata
{ {
[TestFixture] [TestFixture]
public class TestMediaScannerTests public class LocalMediaSourcePlannerTests
{ {
private static readonly List<string> VideoFileExtensions = new() private static readonly List<string> VideoFileExtensions = new()
{ {
@ -25,13 +25,13 @@ namespace ErsatzTV.Core.Tests.Metadata
private static IEnumerable<FakeFileSystemEntry> NewEntriesFor(params string[] fileNames) => private static IEnumerable<FakeFileSystemEntry> NewEntriesFor(params string[] fileNames) =>
fileNames.Map(f => new FakeFileSystemEntry(f, DateTime.MaxValue)); fileNames.Map(f => new FakeFileSystemEntry(f, DateTime.MaxValue));
private static TestMediaScanner ScannerForOldFiles(params string[] fileNames) private static LocalMediaSourcePlanner ScannerForOldFiles(params string[] fileNames)
=> new(new FakeLocalFileSystem(OldEntriesFor(fileNames))); => new(new FakeLocalFileSystem(OldEntriesFor(fileNames)));
private static TestMediaScanner ScannerForNewFiles(params string[] fileNames) private static LocalMediaSourcePlanner ScannerForNewFiles(params string[] fileNames)
=> new(new FakeLocalFileSystem(NewEntriesFor(fileNames))); => new(new FakeLocalFileSystem(NewEntriesFor(fileNames)));
private static TestMediaScanner ScannerFor(IEnumerable<FakeFileSystemEntry> entries) private static LocalMediaSourcePlanner ScannerFor(IEnumerable<FakeFileSystemEntry> entries)
=> new(new FakeLocalFileSystem(entries)); => new(new FakeLocalFileSystem(entries));
[TestFixture] [TestFixture]
@ -39,30 +39,31 @@ namespace ErsatzTV.Core.Tests.Metadata
{ {
[Test] [Test]
public void WithoutNfo_WithoutPoster( public void WithoutNfo_WithoutPoster(
[ValueSource(typeof(TestMediaScannerTests), nameof(VideoFileExtensions))] [ValueSource(typeof(LocalMediaSourcePlannerTests), nameof(VideoFileExtensions))]
string extension) string extension)
{ {
var movieFileName = $"/movies/test (2021)/test (2021).{extension}"; var movieFileName = $"/movies/test (2021)/test (2021).{extension}";
string[] fileNames = { movieFileName }; string[] fileNames = { movieFileName };
Seq<LocalMediaItemScanningPlan> result = ScannerForOldFiles(fileNames).DetermineActions( Seq<LocalMediaSourcePlan> result = ScannerForOldFiles(fileNames).DetermineActions(
MediaType.Movie, MediaType.Movie,
Seq<MediaItem>.Empty, Seq<MediaItem>.Empty,
fileNames.ToSeq()); fileNames.ToSeq());
result.Count.Should().Be(1); result.Count.Should().Be(1);
(Either<string, MediaItem> source, List<ItemScanningPlan> itemScanningPlans) = result.Head(); (Either<string, MediaItem> source, List<ActionPlan> itemScanningPlans) = result.Head();
source.IsLeft.Should().BeTrue(); source.IsLeft.Should().BeTrue();
source.LeftToSeq().Should().BeEquivalentTo(movieFileName); source.LeftToSeq().Should().BeEquivalentTo(movieFileName);
itemScanningPlans.Should().BeEquivalentTo( itemScanningPlans.Should().BeEquivalentTo(
new ItemScanningPlan(movieFileName, ScanningAction.Statistics), new ActionPlan(movieFileName, ScanningAction.Add),
new ItemScanningPlan(movieFileName, ScanningAction.FallbackMetadata), new ActionPlan(movieFileName, ScanningAction.Statistics),
new ItemScanningPlan(movieFileName, ScanningAction.Collections)); new ActionPlan(movieFileName, ScanningAction.FallbackMetadata),
new ActionPlan(movieFileName, ScanningAction.Collections));
} }
[Test] [Test]
public void WithNfo_WithoutPoster( public void WithNfo_WithoutPoster(
[ValueSource(typeof(TestMediaScannerTests), nameof(VideoFileExtensions))] [ValueSource(typeof(LocalMediaSourcePlannerTests), nameof(VideoFileExtensions))]
string extension, string extension,
[Values("test (2021).nfo", "movie.nfo")] [Values("test (2021).nfo", "movie.nfo")]
string nfoFile) string nfoFile)
@ -71,24 +72,25 @@ namespace ErsatzTV.Core.Tests.Metadata
var nfoFileName = $"/movies/test (2021)/{nfoFile}"; var nfoFileName = $"/movies/test (2021)/{nfoFile}";
string[] fileNames = { movieFileName, nfoFileName }; string[] fileNames = { movieFileName, nfoFileName };
Seq<LocalMediaItemScanningPlan> result = ScannerForOldFiles(fileNames).DetermineActions( Seq<LocalMediaSourcePlan> result = ScannerForOldFiles(fileNames).DetermineActions(
MediaType.Movie, MediaType.Movie,
Seq<MediaItem>.Empty, Seq<MediaItem>.Empty,
fileNames.ToSeq()); fileNames.ToSeq());
result.Count.Should().Be(1); result.Count.Should().Be(1);
(Either<string, MediaItem> source, List<ItemScanningPlan> itemScanningPlans) = result.Head(); (Either<string, MediaItem> source, List<ActionPlan> itemScanningPlans) = result.Head();
source.IsLeft.Should().BeTrue(); source.IsLeft.Should().BeTrue();
source.LeftToSeq().Should().BeEquivalentTo(movieFileName); source.LeftToSeq().Should().BeEquivalentTo(movieFileName);
itemScanningPlans.Should().BeEquivalentTo( itemScanningPlans.Should().BeEquivalentTo(
new ItemScanningPlan(movieFileName, ScanningAction.Statistics), new ActionPlan(movieFileName, ScanningAction.Add),
new ItemScanningPlan(nfoFileName, ScanningAction.SidecarMetadata), new ActionPlan(movieFileName, ScanningAction.Statistics),
new ItemScanningPlan(nfoFileName, ScanningAction.Collections)); new ActionPlan(nfoFileName, ScanningAction.SidecarMetadata),
new ActionPlan(nfoFileName, ScanningAction.Collections));
} }
[Test] [Test]
public void WithoutNfo_WithPoster( public void WithoutNfo_WithPoster(
[ValueSource(typeof(TestMediaScannerTests), nameof(VideoFileExtensions))] [ValueSource(typeof(LocalMediaSourcePlannerTests), nameof(VideoFileExtensions))]
string extension, string extension,
[Values("", "test (2021)-")] [Values("", "test (2021)-")]
string basePosterName, string basePosterName,
@ -100,25 +102,26 @@ namespace ErsatzTV.Core.Tests.Metadata
string[] fileNames = { movieFileName, posterFileName }; string[] fileNames = { movieFileName, posterFileName };
Seq<LocalMediaItemScanningPlan> result = ScannerForOldFiles(fileNames).DetermineActions( Seq<LocalMediaSourcePlan> result = ScannerForOldFiles(fileNames).DetermineActions(
MediaType.Movie, MediaType.Movie,
Seq<MediaItem>.Empty, Seq<MediaItem>.Empty,
fileNames.ToSeq()); fileNames.ToSeq());
result.Count.Should().Be(1); result.Count.Should().Be(1);
(Either<string, MediaItem> source, List<ItemScanningPlan> itemScanningPlans) = result.Head(); (Either<string, MediaItem> source, List<ActionPlan> itemScanningPlans) = result.Head();
source.IsLeft.Should().BeTrue(); source.IsLeft.Should().BeTrue();
source.LeftToSeq().Should().BeEquivalentTo(movieFileName); source.LeftToSeq().Should().BeEquivalentTo(movieFileName);
itemScanningPlans.Should().BeEquivalentTo( itemScanningPlans.Should().BeEquivalentTo(
new ItemScanningPlan(movieFileName, ScanningAction.Statistics), new ActionPlan(movieFileName, ScanningAction.Add),
new ItemScanningPlan(movieFileName, ScanningAction.FallbackMetadata), new ActionPlan(movieFileName, ScanningAction.Statistics),
new ItemScanningPlan(posterFileName, ScanningAction.Poster), new ActionPlan(movieFileName, ScanningAction.FallbackMetadata),
new ItemScanningPlan(movieFileName, ScanningAction.Collections)); new ActionPlan(posterFileName, ScanningAction.Poster),
new ActionPlan(movieFileName, ScanningAction.Collections));
} }
[Test] [Test]
public void WithNfo_WithPoster( public void WithNfo_WithPoster(
[ValueSource(typeof(TestMediaScannerTests), nameof(VideoFileExtensions))] [ValueSource(typeof(LocalMediaSourcePlannerTests), nameof(VideoFileExtensions))]
string extension, string extension,
[Values("test (2021).nfo", "movie.nfo")] [Values("test (2021).nfo", "movie.nfo")]
string nfoFile, string nfoFile,
@ -133,20 +136,21 @@ namespace ErsatzTV.Core.Tests.Metadata
string[] fileNames = { movieFileName, nfoFileName, posterFileName }; string[] fileNames = { movieFileName, nfoFileName, posterFileName };
Seq<LocalMediaItemScanningPlan> result = ScannerForOldFiles(fileNames).DetermineActions( Seq<LocalMediaSourcePlan> result = ScannerForOldFiles(fileNames).DetermineActions(
MediaType.Movie, MediaType.Movie,
Seq<MediaItem>.Empty, Seq<MediaItem>.Empty,
fileNames.ToSeq()); fileNames.ToSeq());
result.Count.Should().Be(1); result.Count.Should().Be(1);
(Either<string, MediaItem> source, List<ItemScanningPlan> itemScanningPlans) = result.Head(); (Either<string, MediaItem> source, List<ActionPlan> itemScanningPlans) = result.Head();
source.IsLeft.Should().BeTrue(); source.IsLeft.Should().BeTrue();
source.LeftToSeq().Should().BeEquivalentTo(movieFileName); source.LeftToSeq().Should().BeEquivalentTo(movieFileName);
itemScanningPlans.Should().BeEquivalentTo( itemScanningPlans.Should().BeEquivalentTo(
new ItemScanningPlan(movieFileName, ScanningAction.Statistics), new ActionPlan(movieFileName, ScanningAction.Add),
new ItemScanningPlan(nfoFileName, ScanningAction.SidecarMetadata), new ActionPlan(movieFileName, ScanningAction.Statistics),
new ItemScanningPlan(posterFileName, ScanningAction.Poster), new ActionPlan(nfoFileName, ScanningAction.SidecarMetadata),
new ItemScanningPlan(nfoFileName, ScanningAction.Collections)); new ActionPlan(posterFileName, ScanningAction.Poster),
new ActionPlan(nfoFileName, ScanningAction.Collections));
} }
} }
@ -155,7 +159,7 @@ namespace ErsatzTV.Core.Tests.Metadata
{ {
[Test] [Test]
public void Old_File_Should_Do_Nothing( public void Old_File_Should_Do_Nothing(
[ValueSource(typeof(TestMediaScannerTests), nameof(VideoFileExtensions))] [ValueSource(typeof(LocalMediaSourcePlannerTests), nameof(VideoFileExtensions))]
string extension) string extension)
{ {
var movieMediaItem = new MediaItem var movieMediaItem = new MediaItem
@ -166,7 +170,7 @@ namespace ErsatzTV.Core.Tests.Metadata
string[] fileNames = { movieMediaItem.Path }; string[] fileNames = { movieMediaItem.Path };
Seq<LocalMediaItemScanningPlan> result = ScannerForOldFiles(fileNames).DetermineActions( Seq<LocalMediaSourcePlan> result = ScannerForOldFiles(fileNames).DetermineActions(
MediaType.Movie, MediaType.Movie,
Seq.create(movieMediaItem), Seq.create(movieMediaItem),
fileNames.ToSeq()); fileNames.ToSeq());
@ -176,7 +180,7 @@ namespace ErsatzTV.Core.Tests.Metadata
[Test] [Test]
public void Updated_File_Should_Refresh_Statistics( public void Updated_File_Should_Refresh_Statistics(
[ValueSource(typeof(TestMediaScannerTests), nameof(VideoFileExtensions))] [ValueSource(typeof(LocalMediaSourcePlannerTests), nameof(VideoFileExtensions))]
string extension) string extension)
{ {
var movieMediaItem = new MediaItem var movieMediaItem = new MediaItem
@ -187,22 +191,22 @@ namespace ErsatzTV.Core.Tests.Metadata
string[] fileNames = { movieMediaItem.Path }; string[] fileNames = { movieMediaItem.Path };
Seq<LocalMediaItemScanningPlan> result = ScannerForNewFiles(fileNames).DetermineActions( Seq<LocalMediaSourcePlan> result = ScannerForNewFiles(fileNames).DetermineActions(
MediaType.Movie, MediaType.Movie,
Seq.create(movieMediaItem), Seq.create(movieMediaItem),
fileNames.ToSeq()); fileNames.ToSeq());
result.Count.Should().Be(1); result.Count.Should().Be(1);
(Either<string, MediaItem> source, List<ItemScanningPlan> itemScanningPlans) = result.Head(); (Either<string, MediaItem> source, List<ActionPlan> itemScanningPlans) = result.Head();
source.IsRight.Should().BeTrue(); source.IsRight.Should().BeTrue();
source.RightToSeq().Should().BeEquivalentTo(movieMediaItem); source.RightToSeq().Should().BeEquivalentTo(movieMediaItem);
itemScanningPlans.Should().BeEquivalentTo( itemScanningPlans.Should().BeEquivalentTo(
new ItemScanningPlan(movieMediaItem.Path, ScanningAction.Statistics)); new ActionPlan(movieMediaItem.Path, ScanningAction.Statistics));
} }
[Test] [Test]
public void Fallback_WithNewNfo_WithoutPoster( public void Fallback_WithNewNfo_WithoutPoster(
[ValueSource(typeof(TestMediaScannerTests), nameof(VideoFileExtensions))] [ValueSource(typeof(LocalMediaSourcePlannerTests), nameof(VideoFileExtensions))]
string extension, string extension,
[Values("test (2021).nfo", "movie.nfo")] [Values("test (2021).nfo", "movie.nfo")]
string nfoFile) string nfoFile)
@ -216,23 +220,23 @@ namespace ErsatzTV.Core.Tests.Metadata
var nfoFileName = $"/movies/test (2021)/{nfoFile}"; var nfoFileName = $"/movies/test (2021)/{nfoFile}";
string[] fileNames = { movieMediaItem.Path, nfoFileName }; string[] fileNames = { movieMediaItem.Path, nfoFileName };
Seq<LocalMediaItemScanningPlan> result = ScannerForOldFiles(fileNames).DetermineActions( Seq<LocalMediaSourcePlan> result = ScannerForOldFiles(fileNames).DetermineActions(
MediaType.Movie, MediaType.Movie,
Seq.create(movieMediaItem), Seq.create(movieMediaItem),
fileNames.ToSeq()); fileNames.ToSeq());
result.Count.Should().Be(1); result.Count.Should().Be(1);
(Either<string, MediaItem> source, List<ItemScanningPlan> itemScanningPlans) = result.Head(); (Either<string, MediaItem> source, List<ActionPlan> itemScanningPlans) = result.Head();
source.IsRight.Should().BeTrue(); source.IsRight.Should().BeTrue();
source.RightToSeq().Should().BeEquivalentTo(movieMediaItem); source.RightToSeq().Should().BeEquivalentTo(movieMediaItem);
itemScanningPlans.Should().BeEquivalentTo( itemScanningPlans.Should().BeEquivalentTo(
new ItemScanningPlan(nfoFileName, ScanningAction.SidecarMetadata), new ActionPlan(nfoFileName, ScanningAction.SidecarMetadata),
new ItemScanningPlan(nfoFileName, ScanningAction.Collections)); new ActionPlan(nfoFileName, ScanningAction.Collections));
} }
[Test] [Test]
public void Sidecar_WithOldNfo_WithoutPoster( public void Sidecar_WithOldNfo_WithoutPoster(
[ValueSource(typeof(TestMediaScannerTests), nameof(VideoFileExtensions))] [ValueSource(typeof(LocalMediaSourcePlannerTests), nameof(VideoFileExtensions))]
string extension, string extension,
[Values("test (2021).nfo", "movie.nfo")] [Values("test (2021).nfo", "movie.nfo")]
string nfoFile) string nfoFile)
@ -246,7 +250,7 @@ namespace ErsatzTV.Core.Tests.Metadata
var nfoFileName = $"/movies/test (2021)/{nfoFile}"; var nfoFileName = $"/movies/test (2021)/{nfoFile}";
string[] fileNames = { movieMediaItem.Path, nfoFileName }; string[] fileNames = { movieMediaItem.Path, nfoFileName };
Seq<LocalMediaItemScanningPlan> result = ScannerForOldFiles(fileNames).DetermineActions( Seq<LocalMediaSourcePlan> result = ScannerForOldFiles(fileNames).DetermineActions(
MediaType.Movie, MediaType.Movie,
Seq.create(movieMediaItem), Seq.create(movieMediaItem),
fileNames.ToSeq()); fileNames.ToSeq());
@ -256,7 +260,7 @@ namespace ErsatzTV.Core.Tests.Metadata
[Test] [Test]
public void Sidecar_WithUpdatedNfo_WithoutPoster( public void Sidecar_WithUpdatedNfo_WithoutPoster(
[ValueSource(typeof(TestMediaScannerTests), nameof(VideoFileExtensions))] [ValueSource(typeof(LocalMediaSourcePlannerTests), nameof(VideoFileExtensions))]
string extension, string extension,
[Values("test (2021).nfo", "movie.nfo")] [Values("test (2021).nfo", "movie.nfo")]
string nfoFile) string nfoFile)
@ -270,7 +274,7 @@ namespace ErsatzTV.Core.Tests.Metadata
var nfoFileName = $"/movies/test (2021)/{nfoFile}"; var nfoFileName = $"/movies/test (2021)/{nfoFile}";
string[] fileNames = { movieMediaItem.Path, nfoFileName }; string[] fileNames = { movieMediaItem.Path, nfoFileName };
Seq<LocalMediaItemScanningPlan> result = Seq<LocalMediaSourcePlan> result =
ScannerFor(OldEntriesFor(movieMediaItem.Path).Concat(NewEntriesFor(nfoFileName))) ScannerFor(OldEntriesFor(movieMediaItem.Path).Concat(NewEntriesFor(nfoFileName)))
.DetermineActions( .DetermineActions(
MediaType.Movie, MediaType.Movie,
@ -278,17 +282,17 @@ namespace ErsatzTV.Core.Tests.Metadata
fileNames.ToSeq()); fileNames.ToSeq());
result.Count.Should().Be(1); result.Count.Should().Be(1);
(Either<string, MediaItem> source, List<ItemScanningPlan> itemScanningPlans) = result.Head(); (Either<string, MediaItem> source, List<ActionPlan> itemScanningPlans) = result.Head();
source.IsRight.Should().BeTrue(); source.IsRight.Should().BeTrue();
source.RightToSeq().Should().BeEquivalentTo(movieMediaItem); source.RightToSeq().Should().BeEquivalentTo(movieMediaItem);
itemScanningPlans.Should().BeEquivalentTo( itemScanningPlans.Should().BeEquivalentTo(
new ItemScanningPlan(nfoFileName, ScanningAction.SidecarMetadata), new ActionPlan(nfoFileName, ScanningAction.SidecarMetadata),
new ItemScanningPlan(nfoFileName, ScanningAction.Collections)); new ActionPlan(nfoFileName, ScanningAction.Collections));
} }
[Test] [Test]
public void WithoutNfo_WithNewPoster( public void WithoutNfo_WithNewPoster(
[ValueSource(typeof(TestMediaScannerTests), nameof(VideoFileExtensions))] [ValueSource(typeof(LocalMediaSourcePlannerTests), nameof(VideoFileExtensions))]
string extension, string extension,
[Values("", "test (2021)-")] [Values("", "test (2021)-")]
string basePosterName, string basePosterName,
@ -304,22 +308,22 @@ namespace ErsatzTV.Core.Tests.Metadata
var posterFileName = $"/movies/test (2021)/{basePosterName}poster.{posterExtension}"; var posterFileName = $"/movies/test (2021)/{basePosterName}poster.{posterExtension}";
string[] fileNames = { movieMediaItem.Path, posterFileName }; string[] fileNames = { movieMediaItem.Path, posterFileName };
Seq<LocalMediaItemScanningPlan> result = ScannerForOldFiles(fileNames).DetermineActions( Seq<LocalMediaSourcePlan> result = ScannerForOldFiles(fileNames).DetermineActions(
MediaType.Movie, MediaType.Movie,
Seq.create(movieMediaItem), Seq.create(movieMediaItem),
fileNames.ToSeq()); fileNames.ToSeq());
result.Count.Should().Be(1); result.Count.Should().Be(1);
(Either<string, MediaItem> source, List<ItemScanningPlan> itemScanningPlans) = result.Head(); (Either<string, MediaItem> source, List<ActionPlan> itemScanningPlans) = result.Head();
source.IsRight.Should().BeTrue(); source.IsRight.Should().BeTrue();
source.RightToSeq().Should().BeEquivalentTo(movieMediaItem); source.RightToSeq().Should().BeEquivalentTo(movieMediaItem);
itemScanningPlans.Should() itemScanningPlans.Should()
.BeEquivalentTo(new ItemScanningPlan(posterFileName, ScanningAction.Poster)); .BeEquivalentTo(new ActionPlan(posterFileName, ScanningAction.Poster));
} }
[Test] [Test]
public void WithoutNfo_WithOldPoster( public void WithoutNfo_WithOldPoster(
[ValueSource(typeof(TestMediaScannerTests), nameof(VideoFileExtensions))] [ValueSource(typeof(LocalMediaSourcePlannerTests), nameof(VideoFileExtensions))]
string extension, string extension,
[Values("", "test (2021)-")] [Values("", "test (2021)-")]
string basePosterName, string basePosterName,
@ -337,7 +341,7 @@ namespace ErsatzTV.Core.Tests.Metadata
var posterFileName = $"/movies/test (2021)/{basePosterName}poster.{posterExtension}"; var posterFileName = $"/movies/test (2021)/{basePosterName}poster.{posterExtension}";
string[] fileNames = { movieMediaItem.Path, posterFileName }; string[] fileNames = { movieMediaItem.Path, posterFileName };
Seq<LocalMediaItemScanningPlan> result = ScannerForOldFiles(fileNames).DetermineActions( Seq<LocalMediaSourcePlan> result = ScannerForOldFiles(fileNames).DetermineActions(
MediaType.Movie, MediaType.Movie,
Seq.create(movieMediaItem), Seq.create(movieMediaItem),
fileNames.ToSeq()); fileNames.ToSeq());
@ -347,7 +351,7 @@ namespace ErsatzTV.Core.Tests.Metadata
[Test] [Test]
public void WithoutNfo_WithUpdatedPoster( public void WithoutNfo_WithUpdatedPoster(
[ValueSource(typeof(TestMediaScannerTests), nameof(VideoFileExtensions))] [ValueSource(typeof(LocalMediaSourcePlannerTests), nameof(VideoFileExtensions))]
string extension, string extension,
[Values("", "test (2021)-")] [Values("", "test (2021)-")]
string basePosterName, string basePosterName,
@ -365,7 +369,7 @@ namespace ErsatzTV.Core.Tests.Metadata
var posterFileName = $"/movies/test (2021)/{basePosterName}poster.{posterExtension}"; var posterFileName = $"/movies/test (2021)/{basePosterName}poster.{posterExtension}";
string[] fileNames = { movieMediaItem.Path, posterFileName }; string[] fileNames = { movieMediaItem.Path, posterFileName };
Seq<LocalMediaItemScanningPlan> result = Seq<LocalMediaSourcePlan> result =
ScannerFor(OldEntriesFor(movieMediaItem.Path).Concat(NewEntriesFor(posterFileName))) ScannerFor(OldEntriesFor(movieMediaItem.Path).Concat(NewEntriesFor(posterFileName)))
.DetermineActions( .DetermineActions(
MediaType.Movie, MediaType.Movie,
@ -373,16 +377,16 @@ namespace ErsatzTV.Core.Tests.Metadata
fileNames.ToSeq()); fileNames.ToSeq());
result.Count.Should().Be(1); result.Count.Should().Be(1);
(Either<string, MediaItem> source, List<ItemScanningPlan> itemScanningPlans) = result.Head(); (Either<string, MediaItem> source, List<ActionPlan> itemScanningPlans) = result.Head();
source.IsRight.Should().BeTrue(); source.IsRight.Should().BeTrue();
source.RightToSeq().Should().BeEquivalentTo(movieMediaItem); source.RightToSeq().Should().BeEquivalentTo(movieMediaItem);
itemScanningPlans.Should() itemScanningPlans.Should()
.BeEquivalentTo(new ItemScanningPlan(posterFileName, ScanningAction.Poster)); .BeEquivalentTo(new ActionPlan(posterFileName, ScanningAction.Poster));
} }
[Test] [Test]
public void WithNewNfo_WithNewPoster( public void WithNewNfo_WithNewPoster(
[ValueSource(typeof(TestMediaScannerTests), nameof(VideoFileExtensions))] [ValueSource(typeof(LocalMediaSourcePlannerTests), nameof(VideoFileExtensions))]
string extension, string extension,
[Values("test (2021).nfo", "movie.nfo")] [Values("test (2021).nfo", "movie.nfo")]
string nfoFile, string nfoFile,
@ -401,19 +405,19 @@ namespace ErsatzTV.Core.Tests.Metadata
var posterFileName = $"/movies/test (2021)/{basePosterName}poster.{posterExtension}"; var posterFileName = $"/movies/test (2021)/{basePosterName}poster.{posterExtension}";
string[] fileNames = { movieMediaItem.Path, nfoFileName, posterFileName }; string[] fileNames = { movieMediaItem.Path, nfoFileName, posterFileName };
Seq<LocalMediaItemScanningPlan> result = ScannerForOldFiles(fileNames).DetermineActions( Seq<LocalMediaSourcePlan> result = ScannerForOldFiles(fileNames).DetermineActions(
MediaType.Movie, MediaType.Movie,
Seq.create(movieMediaItem), Seq.create(movieMediaItem),
fileNames.ToSeq()); fileNames.ToSeq());
result.Count.Should().Be(1); result.Count.Should().Be(1);
(Either<string, MediaItem> source, List<ItemScanningPlan> itemScanningPlans) = result.Head(); (Either<string, MediaItem> source, List<ActionPlan> itemScanningPlans) = result.Head();
source.IsRight.Should().BeTrue(); source.IsRight.Should().BeTrue();
source.RightToSeq().Should().BeEquivalentTo(movieMediaItem); source.RightToSeq().Should().BeEquivalentTo(movieMediaItem);
itemScanningPlans.Should().BeEquivalentTo( itemScanningPlans.Should().BeEquivalentTo(
new ItemScanningPlan(nfoFileName, ScanningAction.SidecarMetadata), new ActionPlan(nfoFileName, ScanningAction.SidecarMetadata),
new ItemScanningPlan(posterFileName, ScanningAction.Poster), new ActionPlan(posterFileName, ScanningAction.Poster),
new ItemScanningPlan(nfoFileName, ScanningAction.Collections)); new ActionPlan(nfoFileName, ScanningAction.Collections));
} }
} }
@ -422,54 +426,56 @@ namespace ErsatzTV.Core.Tests.Metadata
{ {
[Test] [Test]
public void WithoutNfo_WithoutPoster( public void WithoutNfo_WithoutPoster(
[ValueSource(typeof(TestMediaScannerTests), nameof(VideoFileExtensions))] [ValueSource(typeof(LocalMediaSourcePlannerTests), nameof(VideoFileExtensions))]
string extension) string extension)
{ {
var episodeFileName = $"/tv/test (2021)/season 01/test (2021) - s01e03.{extension}"; var episodeFileName = $"/tv/test (2021)/season 01/test (2021) - s01e03.{extension}";
string[] fileNames = { episodeFileName }; string[] fileNames = { episodeFileName };
Seq<LocalMediaItemScanningPlan> result = ScannerForOldFiles(fileNames).DetermineActions( Seq<LocalMediaSourcePlan> result = ScannerForOldFiles(fileNames).DetermineActions(
MediaType.TvShow, MediaType.TvShow,
Seq<MediaItem>.Empty, Seq<MediaItem>.Empty,
fileNames.ToSeq()); fileNames.ToSeq());
result.Count.Should().Be(1); result.Count.Should().Be(1);
(Either<string, MediaItem> source, List<ItemScanningPlan> itemScanningPlans) = result.Head(); (Either<string, MediaItem> source, List<ActionPlan> itemScanningPlans) = result.Head();
source.IsLeft.Should().BeTrue(); source.IsLeft.Should().BeTrue();
source.LeftToSeq().Should().BeEquivalentTo(episodeFileName); source.LeftToSeq().Should().BeEquivalentTo(episodeFileName);
itemScanningPlans.Should().BeEquivalentTo( itemScanningPlans.Should().BeEquivalentTo(
new ItemScanningPlan(episodeFileName, ScanningAction.Statistics), new ActionPlan(episodeFileName, ScanningAction.Add),
new ItemScanningPlan(episodeFileName, ScanningAction.FallbackMetadata), new ActionPlan(episodeFileName, ScanningAction.Statistics),
new ItemScanningPlan(episodeFileName, ScanningAction.Collections)); new ActionPlan(episodeFileName, ScanningAction.FallbackMetadata),
new ActionPlan(episodeFileName, ScanningAction.Collections));
} }
[Test] [Test]
public void WithNfo_WithoutPoster( public void WithNfo_WithoutPoster(
[ValueSource(typeof(TestMediaScannerTests), nameof(VideoFileExtensions))] [ValueSource(typeof(LocalMediaSourcePlannerTests), nameof(VideoFileExtensions))]
string extension) string extension)
{ {
var episodeFileName = $"/tv/test (2021)/season 01/test (2021) - s01e03.{extension}"; var episodeFileName = $"/tv/test (2021)/season 01/test (2021) - s01e03.{extension}";
var nfoFileName = "/tv/test (2021)/season 01/test (2021) - s01e03.nfo"; var nfoFileName = "/tv/test (2021)/season 01/test (2021) - s01e03.nfo";
string[] fileNames = { episodeFileName, nfoFileName }; string[] fileNames = { episodeFileName, nfoFileName };
Seq<LocalMediaItemScanningPlan> result = ScannerForOldFiles(fileNames).DetermineActions( Seq<LocalMediaSourcePlan> result = ScannerForOldFiles(fileNames).DetermineActions(
MediaType.TvShow, MediaType.TvShow,
Seq<MediaItem>.Empty, Seq<MediaItem>.Empty,
fileNames.ToSeq()); fileNames.ToSeq());
result.Count.Should().Be(1); result.Count.Should().Be(1);
(Either<string, MediaItem> source, List<ItemScanningPlan> itemScanningPlans) = result.Head(); (Either<string, MediaItem> source, List<ActionPlan> itemScanningPlans) = result.Head();
source.IsLeft.Should().BeTrue(); source.IsLeft.Should().BeTrue();
source.LeftToSeq().Should().BeEquivalentTo(episodeFileName); source.LeftToSeq().Should().BeEquivalentTo(episodeFileName);
itemScanningPlans.Should().BeEquivalentTo( itemScanningPlans.Should().BeEquivalentTo(
new ItemScanningPlan(episodeFileName, ScanningAction.Statistics), new ActionPlan(episodeFileName, ScanningAction.Add),
new ItemScanningPlan(nfoFileName, ScanningAction.SidecarMetadata), new ActionPlan(episodeFileName, ScanningAction.Statistics),
new ItemScanningPlan(nfoFileName, ScanningAction.Collections)); new ActionPlan(nfoFileName, ScanningAction.SidecarMetadata),
new ActionPlan(nfoFileName, ScanningAction.Collections));
} }
[Test] [Test]
public void WithoutNfo_WithPoster( public void WithoutNfo_WithPoster(
[ValueSource(typeof(TestMediaScannerTests), nameof(VideoFileExtensions))] [ValueSource(typeof(LocalMediaSourcePlannerTests), nameof(VideoFileExtensions))]
string extension, string extension,
[Values("jpg", "jpeg", "png", "gif", "tbn")] [Values("jpg", "jpeg", "png", "gif", "tbn")]
string posterExtension) string posterExtension)
@ -479,25 +485,26 @@ namespace ErsatzTV.Core.Tests.Metadata
string[] fileNames = { episodeFileName, posterFileName }; string[] fileNames = { episodeFileName, posterFileName };
Seq<LocalMediaItemScanningPlan> result = ScannerForOldFiles(fileNames).DetermineActions( Seq<LocalMediaSourcePlan> result = ScannerForOldFiles(fileNames).DetermineActions(
MediaType.TvShow, MediaType.TvShow,
Seq<MediaItem>.Empty, Seq<MediaItem>.Empty,
fileNames.ToSeq()); fileNames.ToSeq());
result.Count.Should().Be(1); result.Count.Should().Be(1);
(Either<string, MediaItem> source, List<ItemScanningPlan> itemScanningPlans) = result.Head(); (Either<string, MediaItem> source, List<ActionPlan> itemScanningPlans) = result.Head();
source.IsLeft.Should().BeTrue(); source.IsLeft.Should().BeTrue();
source.LeftToSeq().Should().BeEquivalentTo(episodeFileName); source.LeftToSeq().Should().BeEquivalentTo(episodeFileName);
itemScanningPlans.Should().BeEquivalentTo( itemScanningPlans.Should().BeEquivalentTo(
new ItemScanningPlan(episodeFileName, ScanningAction.Statistics), new ActionPlan(episodeFileName, ScanningAction.Add),
new ItemScanningPlan(episodeFileName, ScanningAction.FallbackMetadata), new ActionPlan(episodeFileName, ScanningAction.Statistics),
new ItemScanningPlan(posterFileName, ScanningAction.Poster), new ActionPlan(episodeFileName, ScanningAction.FallbackMetadata),
new ItemScanningPlan(episodeFileName, ScanningAction.Collections)); new ActionPlan(posterFileName, ScanningAction.Poster),
new ActionPlan(episodeFileName, ScanningAction.Collections));
} }
[Test] [Test]
public void WithNfo_WithPoster( public void WithNfo_WithPoster(
[ValueSource(typeof(TestMediaScannerTests), nameof(VideoFileExtensions))] [ValueSource(typeof(LocalMediaSourcePlannerTests), nameof(VideoFileExtensions))]
string extension, string extension,
[Values("jpg", "jpeg", "png", "gif", "tbn")] [Values("jpg", "jpeg", "png", "gif", "tbn")]
string posterExtension) string posterExtension)
@ -508,20 +515,21 @@ namespace ErsatzTV.Core.Tests.Metadata
string[] fileNames = { episodeFileName, nfoFileName, posterFileName }; string[] fileNames = { episodeFileName, nfoFileName, posterFileName };
Seq<LocalMediaItemScanningPlan> result = ScannerForOldFiles(fileNames).DetermineActions( Seq<LocalMediaSourcePlan> result = ScannerForOldFiles(fileNames).DetermineActions(
MediaType.TvShow, MediaType.TvShow,
Seq<MediaItem>.Empty, Seq<MediaItem>.Empty,
fileNames.ToSeq()); fileNames.ToSeq());
result.Count.Should().Be(1); result.Count.Should().Be(1);
(Either<string, MediaItem> source, List<ItemScanningPlan> itemScanningPlans) = result.Head(); (Either<string, MediaItem> source, List<ActionPlan> itemScanningPlans) = result.Head();
source.IsLeft.Should().BeTrue(); source.IsLeft.Should().BeTrue();
source.LeftToSeq().Should().BeEquivalentTo(episodeFileName); source.LeftToSeq().Should().BeEquivalentTo(episodeFileName);
itemScanningPlans.Should().BeEquivalentTo( itemScanningPlans.Should().BeEquivalentTo(
new ItemScanningPlan(episodeFileName, ScanningAction.Statistics), new ActionPlan(episodeFileName, ScanningAction.Add),
new ItemScanningPlan(nfoFileName, ScanningAction.SidecarMetadata), new ActionPlan(episodeFileName, ScanningAction.Statistics),
new ItemScanningPlan(posterFileName, ScanningAction.Poster), new ActionPlan(nfoFileName, ScanningAction.SidecarMetadata),
new ItemScanningPlan(nfoFileName, ScanningAction.Collections)); new ActionPlan(posterFileName, ScanningAction.Poster),
new ActionPlan(nfoFileName, ScanningAction.Collections));
} }
} }
@ -530,7 +538,7 @@ namespace ErsatzTV.Core.Tests.Metadata
{ {
[Test] [Test]
public void Old_File_Should_Do_Nothing( public void Old_File_Should_Do_Nothing(
[ValueSource(typeof(TestMediaScannerTests), nameof(VideoFileExtensions))] [ValueSource(typeof(LocalMediaSourcePlannerTests), nameof(VideoFileExtensions))]
string extension) string extension)
{ {
var episodeMediaItem = new MediaItem var episodeMediaItem = new MediaItem
@ -541,7 +549,7 @@ namespace ErsatzTV.Core.Tests.Metadata
string[] fileNames = { episodeMediaItem.Path }; string[] fileNames = { episodeMediaItem.Path };
Seq<LocalMediaItemScanningPlan> result = ScannerForOldFiles(fileNames).DetermineActions( Seq<LocalMediaSourcePlan> result = ScannerForOldFiles(fileNames).DetermineActions(
MediaType.TvShow, MediaType.TvShow,
Seq.create(episodeMediaItem), Seq.create(episodeMediaItem),
fileNames.ToSeq()); fileNames.ToSeq());
@ -551,7 +559,7 @@ namespace ErsatzTV.Core.Tests.Metadata
[Test] [Test]
public void Updated_File_Should_Refresh_Statistics( public void Updated_File_Should_Refresh_Statistics(
[ValueSource(typeof(TestMediaScannerTests), nameof(VideoFileExtensions))] [ValueSource(typeof(LocalMediaSourcePlannerTests), nameof(VideoFileExtensions))]
string extension) string extension)
{ {
var episodeMediaItem = new MediaItem var episodeMediaItem = new MediaItem
@ -562,22 +570,22 @@ namespace ErsatzTV.Core.Tests.Metadata
string[] fileNames = { episodeMediaItem.Path }; string[] fileNames = { episodeMediaItem.Path };
Seq<LocalMediaItemScanningPlan> result = ScannerForNewFiles(fileNames).DetermineActions( Seq<LocalMediaSourcePlan> result = ScannerForNewFiles(fileNames).DetermineActions(
MediaType.TvShow, MediaType.TvShow,
Seq.create(episodeMediaItem), Seq.create(episodeMediaItem),
fileNames.ToSeq()); fileNames.ToSeq());
result.Count.Should().Be(1); result.Count.Should().Be(1);
(Either<string, MediaItem> source, List<ItemScanningPlan> itemScanningPlans) = result.Head(); (Either<string, MediaItem> source, List<ActionPlan> itemScanningPlans) = result.Head();
source.IsRight.Should().BeTrue(); source.IsRight.Should().BeTrue();
source.RightToSeq().Should().BeEquivalentTo(episodeMediaItem); source.RightToSeq().Should().BeEquivalentTo(episodeMediaItem);
itemScanningPlans.Should().BeEquivalentTo( itemScanningPlans.Should().BeEquivalentTo(
new ItemScanningPlan(episodeMediaItem.Path, ScanningAction.Statistics)); new ActionPlan(episodeMediaItem.Path, ScanningAction.Statistics));
} }
[Test] [Test]
public void Fallback_WithNewNfo_WithoutPoster( public void Fallback_WithNewNfo_WithoutPoster(
[ValueSource(typeof(TestMediaScannerTests), nameof(VideoFileExtensions))] [ValueSource(typeof(LocalMediaSourcePlannerTests), nameof(VideoFileExtensions))]
string extension) string extension)
{ {
var episodeMediaItem = new MediaItem var episodeMediaItem = new MediaItem
@ -589,23 +597,23 @@ namespace ErsatzTV.Core.Tests.Metadata
var nfoFileName = "/tv/test (2021)/season 01/test (2021) - s01e03.nfo"; var nfoFileName = "/tv/test (2021)/season 01/test (2021) - s01e03.nfo";
string[] fileNames = { episodeMediaItem.Path, nfoFileName }; string[] fileNames = { episodeMediaItem.Path, nfoFileName };
Seq<LocalMediaItemScanningPlan> result = ScannerForOldFiles(fileNames).DetermineActions( Seq<LocalMediaSourcePlan> result = ScannerForOldFiles(fileNames).DetermineActions(
MediaType.TvShow, MediaType.TvShow,
Seq.create(episodeMediaItem), Seq.create(episodeMediaItem),
fileNames.ToSeq()); fileNames.ToSeq());
result.Count.Should().Be(1); result.Count.Should().Be(1);
(Either<string, MediaItem> source, List<ItemScanningPlan> itemScanningPlans) = result.Head(); (Either<string, MediaItem> source, List<ActionPlan> itemScanningPlans) = result.Head();
source.IsRight.Should().BeTrue(); source.IsRight.Should().BeTrue();
source.RightToSeq().Should().BeEquivalentTo(episodeMediaItem); source.RightToSeq().Should().BeEquivalentTo(episodeMediaItem);
itemScanningPlans.Should().BeEquivalentTo( itemScanningPlans.Should().BeEquivalentTo(
new ItemScanningPlan(nfoFileName, ScanningAction.SidecarMetadata), new ActionPlan(nfoFileName, ScanningAction.SidecarMetadata),
new ItemScanningPlan(nfoFileName, ScanningAction.Collections)); new ActionPlan(nfoFileName, ScanningAction.Collections));
} }
[Test] [Test]
public void Sidecar_WithOldNfo_WithoutPoster( public void Sidecar_WithOldNfo_WithoutPoster(
[ValueSource(typeof(TestMediaScannerTests), nameof(VideoFileExtensions))] [ValueSource(typeof(LocalMediaSourcePlannerTests), nameof(VideoFileExtensions))]
string extension) string extension)
{ {
var episodeMediaItem = new MediaItem var episodeMediaItem = new MediaItem
@ -617,7 +625,7 @@ namespace ErsatzTV.Core.Tests.Metadata
var nfoFileName = "/tv/test (2021)/season 01/test (2021) - s01e03.nfo"; var nfoFileName = "/tv/test (2021)/season 01/test (2021) - s01e03.nfo";
string[] fileNames = { episodeMediaItem.Path, nfoFileName }; string[] fileNames = { episodeMediaItem.Path, nfoFileName };
Seq<LocalMediaItemScanningPlan> result = ScannerForOldFiles(fileNames).DetermineActions( Seq<LocalMediaSourcePlan> result = ScannerForOldFiles(fileNames).DetermineActions(
MediaType.TvShow, MediaType.TvShow,
Seq.create(episodeMediaItem), Seq.create(episodeMediaItem),
fileNames.ToSeq()); fileNames.ToSeq());
@ -627,7 +635,7 @@ namespace ErsatzTV.Core.Tests.Metadata
[Test] [Test]
public void Sidecar_WithUpdatedNfo_WithoutPoster( public void Sidecar_WithUpdatedNfo_WithoutPoster(
[ValueSource(typeof(TestMediaScannerTests), nameof(VideoFileExtensions))] [ValueSource(typeof(LocalMediaSourcePlannerTests), nameof(VideoFileExtensions))]
string extension) string extension)
{ {
var episodeMediaItem = new MediaItem var episodeMediaItem = new MediaItem
@ -639,7 +647,7 @@ namespace ErsatzTV.Core.Tests.Metadata
var nfoFileName = "/tv/test (2021)/season 01/test (2021) - s01e03.nfo"; var nfoFileName = "/tv/test (2021)/season 01/test (2021) - s01e03.nfo";
string[] fileNames = { episodeMediaItem.Path, nfoFileName }; string[] fileNames = { episodeMediaItem.Path, nfoFileName };
Seq<LocalMediaItemScanningPlan> result = Seq<LocalMediaSourcePlan> result =
ScannerFor(OldEntriesFor(episodeMediaItem.Path).Concat(NewEntriesFor(nfoFileName))) ScannerFor(OldEntriesFor(episodeMediaItem.Path).Concat(NewEntriesFor(nfoFileName)))
.DetermineActions( .DetermineActions(
MediaType.TvShow, MediaType.TvShow,
@ -647,17 +655,17 @@ namespace ErsatzTV.Core.Tests.Metadata
fileNames.ToSeq()); fileNames.ToSeq());
result.Count.Should().Be(1); result.Count.Should().Be(1);
(Either<string, MediaItem> source, List<ItemScanningPlan> itemScanningPlans) = result.Head(); (Either<string, MediaItem> source, List<ActionPlan> itemScanningPlans) = result.Head();
source.IsRight.Should().BeTrue(); source.IsRight.Should().BeTrue();
source.RightToSeq().Should().BeEquivalentTo(episodeMediaItem); source.RightToSeq().Should().BeEquivalentTo(episodeMediaItem);
itemScanningPlans.Should().BeEquivalentTo( itemScanningPlans.Should().BeEquivalentTo(
new ItemScanningPlan(nfoFileName, ScanningAction.SidecarMetadata), new ActionPlan(nfoFileName, ScanningAction.SidecarMetadata),
new ItemScanningPlan(nfoFileName, ScanningAction.Collections)); new ActionPlan(nfoFileName, ScanningAction.Collections));
} }
[Test] [Test]
public void WithoutNfo_WithNewPoster( public void WithoutNfo_WithNewPoster(
[ValueSource(typeof(TestMediaScannerTests), nameof(VideoFileExtensions))] [ValueSource(typeof(LocalMediaSourcePlannerTests), nameof(VideoFileExtensions))]
string extension, string extension,
[Values("jpg", "jpeg", "png", "gif", "tbn")] [Values("jpg", "jpeg", "png", "gif", "tbn")]
string posterExtension) string posterExtension)
@ -671,22 +679,22 @@ namespace ErsatzTV.Core.Tests.Metadata
var posterFileName = $"/tv/test (2021)/poster.{posterExtension}"; var posterFileName = $"/tv/test (2021)/poster.{posterExtension}";
string[] fileNames = { episodeMediaItem.Path, posterFileName }; string[] fileNames = { episodeMediaItem.Path, posterFileName };
Seq<LocalMediaItemScanningPlan> result = ScannerForOldFiles(fileNames).DetermineActions( Seq<LocalMediaSourcePlan> result = ScannerForOldFiles(fileNames).DetermineActions(
MediaType.TvShow, MediaType.TvShow,
Seq.create(episodeMediaItem), Seq.create(episodeMediaItem),
fileNames.ToSeq()); fileNames.ToSeq());
result.Count.Should().Be(1); result.Count.Should().Be(1);
(Either<string, MediaItem> source, List<ItemScanningPlan> itemScanningPlans) = result.Head(); (Either<string, MediaItem> source, List<ActionPlan> itemScanningPlans) = result.Head();
source.IsRight.Should().BeTrue(); source.IsRight.Should().BeTrue();
source.RightToSeq().Should().BeEquivalentTo(episodeMediaItem); source.RightToSeq().Should().BeEquivalentTo(episodeMediaItem);
itemScanningPlans.Should() itemScanningPlans.Should()
.BeEquivalentTo(new ItemScanningPlan(posterFileName, ScanningAction.Poster)); .BeEquivalentTo(new ActionPlan(posterFileName, ScanningAction.Poster));
} }
[Test] [Test]
public void WithoutNfo_WithOldPoster( public void WithoutNfo_WithOldPoster(
[ValueSource(typeof(TestMediaScannerTests), nameof(VideoFileExtensions))] [ValueSource(typeof(LocalMediaSourcePlannerTests), nameof(VideoFileExtensions))]
string extension, string extension,
[Values("jpg", "jpeg", "png", "gif", "tbn")] [Values("jpg", "jpeg", "png", "gif", "tbn")]
string posterExtension) string posterExtension)
@ -702,7 +710,7 @@ namespace ErsatzTV.Core.Tests.Metadata
var posterFileName = $"/tv/test (2021)/poster.{posterExtension}"; var posterFileName = $"/tv/test (2021)/poster.{posterExtension}";
string[] fileNames = { episodeMediaItem.Path, posterFileName }; string[] fileNames = { episodeMediaItem.Path, posterFileName };
Seq<LocalMediaItemScanningPlan> result = ScannerForOldFiles(fileNames).DetermineActions( Seq<LocalMediaSourcePlan> result = ScannerForOldFiles(fileNames).DetermineActions(
MediaType.TvShow, MediaType.TvShow,
Seq.create(episodeMediaItem), Seq.create(episodeMediaItem),
fileNames.ToSeq()); fileNames.ToSeq());
@ -712,7 +720,7 @@ namespace ErsatzTV.Core.Tests.Metadata
[Test] [Test]
public void WithoutNfo_WithUpdatedPoster( public void WithoutNfo_WithUpdatedPoster(
[ValueSource(typeof(TestMediaScannerTests), nameof(VideoFileExtensions))] [ValueSource(typeof(LocalMediaSourcePlannerTests), nameof(VideoFileExtensions))]
string extension, string extension,
[Values("jpg", "jpeg", "png", "gif", "tbn")] [Values("jpg", "jpeg", "png", "gif", "tbn")]
string posterExtension) string posterExtension)
@ -728,7 +736,7 @@ namespace ErsatzTV.Core.Tests.Metadata
var posterFileName = $"/tv/test (2021)/poster.{posterExtension}"; var posterFileName = $"/tv/test (2021)/poster.{posterExtension}";
string[] fileNames = { episodeMediaItem.Path, posterFileName }; string[] fileNames = { episodeMediaItem.Path, posterFileName };
Seq<LocalMediaItemScanningPlan> result = Seq<LocalMediaSourcePlan> result =
ScannerFor(OldEntriesFor(episodeMediaItem.Path).Concat(NewEntriesFor(posterFileName))) ScannerFor(OldEntriesFor(episodeMediaItem.Path).Concat(NewEntriesFor(posterFileName)))
.DetermineActions( .DetermineActions(
MediaType.TvShow, MediaType.TvShow,
@ -736,16 +744,16 @@ namespace ErsatzTV.Core.Tests.Metadata
fileNames.ToSeq()); fileNames.ToSeq());
result.Count.Should().Be(1); result.Count.Should().Be(1);
(Either<string, MediaItem> source, List<ItemScanningPlan> itemScanningPlans) = result.Head(); (Either<string, MediaItem> source, List<ActionPlan> itemScanningPlans) = result.Head();
source.IsRight.Should().BeTrue(); source.IsRight.Should().BeTrue();
source.RightToSeq().Should().BeEquivalentTo(episodeMediaItem); source.RightToSeq().Should().BeEquivalentTo(episodeMediaItem);
itemScanningPlans.Should() itemScanningPlans.Should()
.BeEquivalentTo(new ItemScanningPlan(posterFileName, ScanningAction.Poster)); .BeEquivalentTo(new ActionPlan(posterFileName, ScanningAction.Poster));
} }
[Test] [Test]
public void WithNewNfo_WithNewPoster( public void WithNewNfo_WithNewPoster(
[ValueSource(typeof(TestMediaScannerTests), nameof(VideoFileExtensions))] [ValueSource(typeof(LocalMediaSourcePlannerTests), nameof(VideoFileExtensions))]
string extension, string extension,
[Values("jpg", "jpeg", "png", "gif", "tbn")] [Values("jpg", "jpeg", "png", "gif", "tbn")]
string posterExtension) string posterExtension)
@ -760,19 +768,19 @@ namespace ErsatzTV.Core.Tests.Metadata
var posterFileName = $"/tv/test (2021)/poster.{posterExtension}"; var posterFileName = $"/tv/test (2021)/poster.{posterExtension}";
string[] fileNames = { episodeMediaItem.Path, nfoFileName, posterFileName }; string[] fileNames = { episodeMediaItem.Path, nfoFileName, posterFileName };
Seq<LocalMediaItemScanningPlan> result = ScannerForOldFiles(fileNames).DetermineActions( Seq<LocalMediaSourcePlan> result = ScannerForOldFiles(fileNames).DetermineActions(
MediaType.TvShow, MediaType.TvShow,
Seq.create(episodeMediaItem), Seq.create(episodeMediaItem),
fileNames.ToSeq()); fileNames.ToSeq());
result.Count.Should().Be(1); result.Count.Should().Be(1);
(Either<string, MediaItem> source, List<ItemScanningPlan> itemScanningPlans) = result.Head(); (Either<string, MediaItem> source, List<ActionPlan> itemScanningPlans) = result.Head();
source.IsRight.Should().BeTrue(); source.IsRight.Should().BeTrue();
source.RightToSeq().Should().BeEquivalentTo(episodeMediaItem); source.RightToSeq().Should().BeEquivalentTo(episodeMediaItem);
itemScanningPlans.Should().BeEquivalentTo( itemScanningPlans.Should().BeEquivalentTo(
new ItemScanningPlan(nfoFileName, ScanningAction.SidecarMetadata), new ActionPlan(nfoFileName, ScanningAction.SidecarMetadata),
new ItemScanningPlan(posterFileName, ScanningAction.Poster), new ActionPlan(posterFileName, ScanningAction.Poster),
new ItemScanningPlan(nfoFileName, ScanningAction.Collections)); new ActionPlan(nfoFileName, ScanningAction.Collections));
} }
} }
@ -790,12 +798,12 @@ namespace ErsatzTV.Core.Tests.Metadata
"/movies/test (2021)/Extras/test (2021)", "/movies/test (2021)/Extras/test (2021)",
"/movies/test (2021)/Specials/test (2021)")] "/movies/test (2021)/Specials/test (2021)")]
string baseFileName, string baseFileName,
[ValueSource(typeof(TestMediaScannerTests), nameof(VideoFileExtensions))] [ValueSource(typeof(LocalMediaSourcePlannerTests), nameof(VideoFileExtensions))]
string extension) string extension)
{ {
string[] fileNames = { $"{baseFileName}.{extension}" }; string[] fileNames = { $"{baseFileName}.{extension}" };
Seq<LocalMediaItemScanningPlan> result = ScannerForOldFiles(fileNames).DetermineActions( Seq<LocalMediaSourcePlan> result = ScannerForOldFiles(fileNames).DetermineActions(
MediaType.Movie, MediaType.Movie,
Seq<MediaItem>.Empty, Seq<MediaItem>.Empty,
fileNames.ToSeq()); fileNames.ToSeq());
@ -815,12 +823,12 @@ namespace ErsatzTV.Core.Tests.Metadata
"/movies/test (2021)/test (2021)-trailer", "/movies/test (2021)/test (2021)-trailer",
"/movies/test (2021)/test (2021)-other")] "/movies/test (2021)/test (2021)-other")]
string baseFileName, string baseFileName,
[ValueSource(typeof(TestMediaScannerTests), nameof(VideoFileExtensions))] [ValueSource(typeof(LocalMediaSourcePlannerTests), nameof(VideoFileExtensions))]
string extension) string extension)
{ {
string[] fileNames = { $"{baseFileName}.{extension}" }; string[] fileNames = { $"{baseFileName}.{extension}" };
Seq<LocalMediaItemScanningPlan> result = ScannerForOldFiles(fileNames).DetermineActions( Seq<LocalMediaSourcePlan> result = ScannerForOldFiles(fileNames).DetermineActions(
MediaType.Movie, MediaType.Movie,
Seq<MediaItem>.Empty, Seq<MediaItem>.Empty,
fileNames.ToSeq()); fileNames.ToSeq());
@ -830,7 +838,7 @@ namespace ErsatzTV.Core.Tests.Metadata
[Test] [Test]
public void Movies_Should_Remove_Missing_MediaItems( public void Movies_Should_Remove_Missing_MediaItems(
[ValueSource(typeof(TestMediaScannerTests), nameof(VideoFileExtensions))] [ValueSource(typeof(LocalMediaSourcePlannerTests), nameof(VideoFileExtensions))]
string extension) string extension)
{ {
var movieMediaItem = new MediaItem var movieMediaItem = new MediaItem
@ -847,29 +855,29 @@ namespace ErsatzTV.Core.Tests.Metadata
string[] fileNames = { "anything" }; string[] fileNames = { "anything" };
Seq<LocalMediaItemScanningPlan> result = ScannerForOldFiles(fileNames).DetermineActions( Seq<LocalMediaSourcePlan> result = ScannerForOldFiles(fileNames).DetermineActions(
MediaType.Movie, MediaType.Movie,
Seq.create(movieMediaItem, movieMediaItem2), Seq.create(movieMediaItem, movieMediaItem2),
fileNames.ToSeq()); fileNames.ToSeq());
result.Count.Should().Be(2); result.Count.Should().Be(2);
(Either<string, MediaItem> source1, List<ItemScanningPlan> itemScanningPlans1) = result.Head(); (Either<string, MediaItem> source1, List<ActionPlan> itemScanningPlans1) = result.Head();
source1.IsRight.Should().BeTrue(); source1.IsRight.Should().BeTrue();
source1.RightToSeq().Should().BeEquivalentTo(movieMediaItem); source1.RightToSeq().Should().BeEquivalentTo(movieMediaItem);
itemScanningPlans1.Should().BeEquivalentTo( itemScanningPlans1.Should().BeEquivalentTo(
new ItemScanningPlan(movieMediaItem.Path, ScanningAction.Remove)); new ActionPlan(movieMediaItem.Path, ScanningAction.Remove));
(Either<string, MediaItem> source2, List<ItemScanningPlan> itemScanningPlans2) = result.Last(); (Either<string, MediaItem> source2, List<ActionPlan> itemScanningPlans2) = result.Last();
source2.IsRight.Should().BeTrue(); source2.IsRight.Should().BeTrue();
source2.RightToSeq().Should().BeEquivalentTo(movieMediaItem2); source2.RightToSeq().Should().BeEquivalentTo(movieMediaItem2);
itemScanningPlans2.Should().BeEquivalentTo( itemScanningPlans2.Should().BeEquivalentTo(
new ItemScanningPlan(movieMediaItem2.Path, ScanningAction.Remove)); new ActionPlan(movieMediaItem2.Path, ScanningAction.Remove));
} }
[Test] [Test]
public void Episodes_Should_Remove_Missing_MediaItems( public void Episodes_Should_Remove_Missing_MediaItems(
[ValueSource(typeof(TestMediaScannerTests), nameof(VideoFileExtensions))] [ValueSource(typeof(LocalMediaSourcePlannerTests), nameof(VideoFileExtensions))]
string extension) string extension)
{ {
var movieMediaItem = new MediaItem var movieMediaItem = new MediaItem
@ -886,24 +894,24 @@ namespace ErsatzTV.Core.Tests.Metadata
string[] fileNames = { "anything" }; string[] fileNames = { "anything" };
Seq<LocalMediaItemScanningPlan> result = ScannerForOldFiles(fileNames).DetermineActions( Seq<LocalMediaSourcePlan> result = ScannerForOldFiles(fileNames).DetermineActions(
MediaType.TvShow, MediaType.TvShow,
Seq.create(movieMediaItem, movieMediaItem2), Seq.create(movieMediaItem, movieMediaItem2),
fileNames.ToSeq()); fileNames.ToSeq());
result.Count.Should().Be(2); result.Count.Should().Be(2);
(Either<string, MediaItem> source1, List<ItemScanningPlan> itemScanningPlans1) = result.Head(); (Either<string, MediaItem> source1, List<ActionPlan> itemScanningPlans1) = result.Head();
source1.IsRight.Should().BeTrue(); source1.IsRight.Should().BeTrue();
source1.RightToSeq().Should().BeEquivalentTo(movieMediaItem); source1.RightToSeq().Should().BeEquivalentTo(movieMediaItem);
itemScanningPlans1.Should().BeEquivalentTo( itemScanningPlans1.Should().BeEquivalentTo(
new ItemScanningPlan(movieMediaItem.Path, ScanningAction.Remove)); new ActionPlan(movieMediaItem.Path, ScanningAction.Remove));
(Either<string, MediaItem> source2, List<ItemScanningPlan> itemScanningPlans2) = result.Last(); (Either<string, MediaItem> source2, List<ActionPlan> itemScanningPlans2) = result.Last();
source2.IsRight.Should().BeTrue(); source2.IsRight.Should().BeTrue();
source2.RightToSeq().Should().BeEquivalentTo(movieMediaItem2); source2.RightToSeq().Should().BeEquivalentTo(movieMediaItem2);
itemScanningPlans2.Should().BeEquivalentTo( itemScanningPlans2.Should().BeEquivalentTo(
new ItemScanningPlan(movieMediaItem2.Path, ScanningAction.Remove)); new ActionPlan(movieMediaItem2.Path, ScanningAction.Remove));
} }
private class FakeLocalFileSystem : ILocalFileSystem private class FakeLocalFileSystem : ILocalFileSystem

14
ErsatzTV.Core/Interfaces/Metadata/ILocalMediaSourcePlanner.cs

@ -0,0 +1,14 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Metadata;
using LanguageExt;
namespace ErsatzTV.Core.Interfaces.Metadata
{
public interface ILocalMediaSourcePlanner
{
public Seq<LocalMediaSourcePlan> DetermineActions(
MediaType mediaType,
Seq<MediaItem> mediaItems,
Seq<string> files);
}
}

3
ErsatzTV.Core/Interfaces/Metadata/ILocalMetadataProvider.cs

@ -5,6 +5,7 @@ namespace ErsatzTV.Core.Interfaces.Metadata
{ {
public interface ILocalMetadataProvider public interface ILocalMetadataProvider
{ {
Task RefreshMetadata(MediaItem mediaItem); Task RefreshSidecarMetadata(MediaItem mediaItem, string path);
Task RefreshFallbackMetadata(MediaItem mediaItem);
} }
} }

4
ErsatzTV.Core/Metadata/ActionPlan.cs

@ -0,0 +1,4 @@
namespace ErsatzTV.Core.Metadata
{
public record ActionPlan(string TargetPath, ScanningAction TargetAction);
}

4
ErsatzTV.Core/Metadata/ItemScanningPlan.cs

@ -1,4 +0,0 @@
namespace ErsatzTV.Core.Metadata
{
public record ItemScanningPlan(string TargetPath, ScanningAction TargetAction);
}

8
ErsatzTV.Core/Metadata/LocalMediaItemScanningPlan.cs

@ -1,8 +0,0 @@
using System.Collections.Generic;
using ErsatzTV.Core.Domain;
using LanguageExt;
namespace ErsatzTV.Core.Metadata
{
public record LocalMediaItemScanningPlan(Either<string, MediaItem> Source, List<ItemScanningPlan> ActionPlans);
}

283
ErsatzTV.Core/Metadata/LocalMediaScanner.cs

@ -1,22 +1,26 @@
using System.Collections.Generic; using System;
using System.Collections.Generic;
using System.IO; using System.IO;
using System.Linq; using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Images;
using ErsatzTV.Core.Interfaces.Metadata; 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 LanguageExt; using LanguageExt;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using static LanguageExt.Prelude; using static LanguageExt.Prelude;
using Seq = LanguageExt.Seq;
namespace ErsatzTV.Core.Metadata namespace ErsatzTV.Core.Metadata
{ {
public class LocalMediaScanner : ILocalMediaScanner public class LocalMediaScanner : ILocalMediaScanner
{ {
private readonly IImageCache _imageCache;
private readonly ILocalFileSystem _localFileSystem; private readonly ILocalFileSystem _localFileSystem;
private readonly ILocalMediaSourcePlanner _localMediaSourcePlanner;
private readonly ILocalMetadataProvider _localMetadataProvider; private readonly ILocalMetadataProvider _localMetadataProvider;
private readonly ILocalPosterProvider _localPosterProvider;
private readonly ILocalStatisticsProvider _localStatisticsProvider; private readonly ILocalStatisticsProvider _localStatisticsProvider;
private readonly ILogger<LocalMediaScanner> _logger; private readonly ILogger<LocalMediaScanner> _logger;
private readonly IMediaItemRepository _mediaItemRepository; private readonly IMediaItemRepository _mediaItemRepository;
@ -29,21 +33,23 @@ namespace ErsatzTV.Core.Metadata
IPlayoutRepository playoutRepository, IPlayoutRepository playoutRepository,
ILocalStatisticsProvider localStatisticsProvider, ILocalStatisticsProvider localStatisticsProvider,
ILocalMetadataProvider localMetadataProvider, ILocalMetadataProvider localMetadataProvider,
ILocalPosterProvider localPosterProvider,
ISmartCollectionBuilder smartCollectionBuilder, ISmartCollectionBuilder smartCollectionBuilder,
IPlayoutBuilder playoutBuilder, IPlayoutBuilder playoutBuilder,
ILogger<LocalMediaScanner> logger, ILocalMediaSourcePlanner localMediaSourcePlanner,
ILocalFileSystem localFileSystem) ILocalFileSystem localFileSystem,
IImageCache imageCache,
ILogger<LocalMediaScanner> logger)
{ {
_mediaItemRepository = mediaItemRepository; _mediaItemRepository = mediaItemRepository;
_playoutRepository = playoutRepository; _playoutRepository = playoutRepository;
_localStatisticsProvider = localStatisticsProvider; _localStatisticsProvider = localStatisticsProvider;
_localMetadataProvider = localMetadataProvider; _localMetadataProvider = localMetadataProvider;
_localPosterProvider = localPosterProvider;
_smartCollectionBuilder = smartCollectionBuilder; _smartCollectionBuilder = smartCollectionBuilder;
_playoutBuilder = playoutBuilder; _playoutBuilder = playoutBuilder;
_logger = logger; _localMediaSourcePlanner = localMediaSourcePlanner;
_localFileSystem = localFileSystem; _localFileSystem = localFileSystem;
_imageCache = imageCache;
_logger = logger;
} }
public async Task<Unit> ScanLocalMediaSource( public async Task<Unit> ScanLocalMediaSource(
@ -62,85 +68,93 @@ namespace ErsatzTV.Core.Metadata
List<MediaItem> knownMediaItems = await _mediaItemRepository.GetAllByMediaSourceId(localMediaSource.Id); List<MediaItem> knownMediaItems = await _mediaItemRepository.GetAllByMediaSourceId(localMediaSource.Id);
var modifiedPlayoutIds = new List<int>(); var modifiedPlayoutIds = new List<int>();
Seq<string> allFiles = _localFileSystem.FindRelevantVideos(localMediaSource); Seq<LocalMediaSourcePlan> actions = _localMediaSourcePlanner.DetermineActions(
var testScanner = new TestMediaScanner(_localFileSystem);
var actions = testScanner.DetermineActions(
localMediaSource.MediaType, localMediaSource.MediaType,
knownMediaItems.ToSeq(), knownMediaItems.ToSeq(),
FindAllFiles(localMediaSource)); FindAllFiles(localMediaSource));
foreach (LocalMediaItemScanningPlan action in actions) foreach (LocalMediaSourcePlan action in actions)
{ {
foreach (ItemScanningPlan plan in action.ActionPlans) Option<ActionPlan> maybeAddPlan =
action.ActionPlans.SingleOrDefault(plan => plan.TargetAction == ScanningAction.Add);
await maybeAddPlan.IfSomeAsync(
async plan =>
{
Option<MediaItem> maybeMediaItem = await AddMediaItem(localMediaSource, plan.TargetPath);
// any actions other than "add" need to operate on a media item
maybeMediaItem.IfSome(mediaItem => action.Source = mediaItem);
});
foreach (ActionPlan plan in action.ActionPlans.OrderBy(plan => (int) plan.TargetAction))
{ {
string sourcePath = action.Source.Match( string sourcePath = action.Source.Match(
Right: mediaItem => mediaItem.Path, mediaItem => mediaItem.Path,
Left: path => path); path => path);
_logger.LogDebug( _logger.LogDebug(
"Action Plan: {Source} / {File} => {Action}", "{Source}: {Action} with {File}",
Path.GetFileName(sourcePath), Path.GetFileName(sourcePath),
Path.GetRelativePath(Path.GetDirectoryName(sourcePath) ?? string.Empty, plan.TargetPath), plan.TargetAction,
plan.TargetAction); Path.GetRelativePath(Path.GetDirectoryName(sourcePath) ?? string.Empty, plan.TargetPath));
await action.Source.Match(
async mediaItem =>
{
var changed = false;
switch (plan.TargetAction)
{
case ScanningAction.Remove:
await RemoveMissingItem(mediaItem);
break;
case ScanningAction.Poster:
await SavePosterForItem(mediaItem, plan.TargetPath);
break;
case ScanningAction.FallbackMetadata:
await RefreshFallbackMetadataForItem(mediaItem);
break;
case ScanningAction.SidecarMetadata:
await RefreshSidecarMetadataForItem(mediaItem, plan.TargetPath);
break;
case ScanningAction.Statistics:
changed = await RefreshStatisticsForItem(mediaItem, ffprobePath);
break;
case ScanningAction.Collections:
changed = await RefreshCollectionsForItem(mediaItem);
break;
}
if (changed)
{
List<int> ids =
await _playoutRepository.GetPlayoutIdsForMediaItems(Seq.create(mediaItem));
modifiedPlayoutIds.AddRange(ids);
}
},
path =>
{
_logger.LogError("This is a bug, something went wrong processing {Path}", path);
return Task.CompletedTask;
});
} }
} }
// // check if the media item exists foreach (int playoutId in modifiedPlayoutIds.Distinct())
// (Seq<string> newFiles, Seq<MediaItem> existingMediaItems) = allFiles.Map( {
// s => Optional(knownMediaItems.Find(i => i.Path == s)).ToEither(s)) Option<Playout> maybePlayout = await _playoutRepository.GetFull(playoutId);
// .Partition(); await maybePlayout.Match(
// async playout =>
// // remove media items that no longer exist {
// var missingMediaItems = knownMediaItems.Filter(i => !allFiles.Contains(i.Path)).ToSeq(); Playout result = await _playoutBuilder.BuildPlayoutItems(playout, true);
// await RemoveMissingItems(missingMediaItems); await _playoutRepository.Update(result);
// modifiedPlayoutIds.AddRange(await _playoutRepository.GetPlayoutIdsForMediaItems(missingMediaItems)); },
// Task.CompletedTask);
// Seq<MediaItem> staleMetadataMediaItems = scanningMode == ScanningMode.RescanAll }
// ? existingMediaItems
// : existingMediaItems.Filter(i => _localFileSystem.ShouldRefreshMetadata(localMediaSource, i));
// Seq<MediaItem> modifiedMediaItems = await RefreshMetadataForItems(ffprobePath, staleMetadataMediaItems);
// modifiedPlayoutIds.AddRange(await _playoutRepository.GetPlayoutIdsForMediaItems(modifiedMediaItems));
//
// // if new, add and store mtime, refresh metadata
// var addedMediaItems = new List<MediaItem>();
// foreach (string path in newFiles)
// {
// _logger.LogDebug("Adding new media item {MediaItem}", path);
// var mediaItem = new MediaItem
// {
// MediaSourceId = localMediaSource.Id,
// Path = path,
// LastWriteTime = File.GetLastWriteTimeUtc(path)
// };
//
// await _mediaItemRepository.Add(mediaItem);
// await RefreshMetadata(mediaItem, ffprobePath);
// addedMediaItems.Add(mediaItem);
// }
//
// modifiedPlayoutIds.AddRange(await _playoutRepository.GetPlayoutIdsForMediaItems(addedMediaItems.ToSeq()));
//
// Seq<MediaItem> stalePosterMediaItems = existingMediaItems
// .Filter(_localFileSystem.ShouldRefreshPoster)
// .Concat(addedMediaItems);
// await RefreshPosterForItems(stalePosterMediaItems);
//
// foreach (int playoutId in modifiedPlayoutIds.Distinct())
// {
// Option<Playout> maybePlayout = await _playoutRepository.GetFull(playoutId);
// await maybePlayout.Match(
// async playout =>
// {
// Playout result = await _playoutBuilder.BuildPlayoutItems(playout, true);
// await _playoutRepository.Update(result);
// },
// Task.CompletedTask);
// }
return unit; return unit;
} }
private Seq<string> FindAllFiles(LocalMediaSource localMediaSource) private Seq<string> FindAllFiles(LocalMediaSource localMediaSource)
{ {
Seq<string> allDirectories = Directory Seq<string> allDirectories = Directory
@ -152,7 +166,7 @@ namespace ErsatzTV.Core.Metadata
Seq<string> excluded = allDirectories.Filter(path => File.Exists(Path.Combine(path, ".etvignore"))); Seq<string> excluded = allDirectories.Filter(path => File.Exists(Path.Combine(path, ".etvignore")));
Seq<string> relevantDirectories = allDirectories Seq<string> relevantDirectories = allDirectories
.Filter(d => !excluded.Any(d.StartsWith)); .Filter(d => !excluded.Any(d.StartsWith));
// .Filter(d => localMediaSource.MediaType == MediaType.Other || !IsExtrasFolder(d)); // .Filter(d => localMediaSource.MediaType == MediaType.Other || !IsExtrasFolder(d));
return relevantDirectories return relevantDirectories
.Collect(d => Directory.GetFiles(d, "*", SearchOption.TopDirectoryOnly)) .Collect(d => Directory.GetFiles(d, "*", SearchOption.TopDirectoryOnly))
@ -160,65 +174,116 @@ namespace ErsatzTV.Core.Metadata
.ToSeq(); .ToSeq();
} }
private async Task<Seq<MediaItem>> RefreshMetadataForItems( private async Task<Option<MediaItem>> AddMediaItem(MediaSource mediaSource, string path)
string ffprobePath,
Seq<MediaItem> staleMetadataMediaItems)
{ {
var modifiedMediaItems = new List<MediaItem>(); try
foreach (MediaItem mediaItem in staleMetadataMediaItems)
{ {
_logger.LogDebug("Refreshing metadata for media item {MediaItem}", mediaItem.Path); var mediaItem = new MediaItem
if (await RefreshMetadata(mediaItem, ffprobePath))
{ {
// only queue playout rebuilds for media items MediaSourceId = mediaSource.Id,
// where the duration or collections have changed Path = path,
modifiedMediaItems.Add(mediaItem); LastWriteTime = File.GetLastWriteTimeUtc(path)
} };
}
return modifiedMediaItems.ToSeq(); await _mediaItemRepository.Add(mediaItem);
return mediaItem;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to add media item for {Path}", path);
return None;
}
} }
private async Task RefreshPosterForItems(Seq<MediaItem> stalePosterMediaItems) private async Task RemoveMissingItem(MediaItem mediaItem)
{ {
(Seq<MediaItem> movies, Seq<MediaItem> episodes) = stalePosterMediaItems try
.Map(i => Optional(i).Filter(i2 => i2.Metadata?.MediaType == MediaType.TvShow).ToEither(i)) {
.Partition(); await _mediaItemRepository.Delete(mediaItem.Id);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to remove missing local media item {MediaItem}", mediaItem.Path);
}
}
// there's a 1:1 movie:poster, so refresh all private async Task SavePosterForItem(MediaItem mediaItem, string posterPath)
foreach (MediaItem movie in movies) {
try
{ {
_logger.LogDebug("Refreshing poster for media item {MediaItem}", movie.Path); byte[] originalBytes = await File.ReadAllBytesAsync(posterPath);
await _localPosterProvider.RefreshPoster(movie); Either<BaseError, string> maybeHash = await _imageCache.ResizeAndSaveImage(originalBytes, 220, null);
await maybeHash.Match(
hash =>
{
mediaItem.Poster = hash;
mediaItem.PosterLastWriteTime = File.GetLastWriteTimeUtc(posterPath);
return _mediaItemRepository.Update(mediaItem);
},
error =>
{
_logger.LogWarning(
"Unable to save poster to disk from {Path}: {Error}",
posterPath,
error.Value);
return Task.CompletedTask;
});
} }
catch (Exception ex)
{
_logger.LogError(ex, "Failed to refresh poster for media item {MediaItem}", mediaItem.Path);
}
}
// we currently have 1 poster per series, so pick the first from each group private async Task<bool> RefreshStatisticsForItem(MediaItem mediaItem, string ffprobePath)
IEnumerable<MediaItem> episodesToRefresh = episodes.GroupBy(e => e.Metadata.Title) {
.SelectMany(g => (Option<MediaItem>) g.FirstOrDefault()); try
{
return await _localStatisticsProvider.RefreshStatistics(ffprobePath, mediaItem);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to refresh statistics for media item {MediaItem}", mediaItem.Path);
return false;
}
}
foreach (MediaItem episode in episodesToRefresh) private async Task<bool> RefreshCollectionsForItem(MediaItem mediaItem)
{
try
{
return await _smartCollectionBuilder.RefreshSmartCollections(mediaItem);
}
catch (Exception ex)
{ {
_logger.LogDebug("Refreshing poster for media item {MediaItem}", episode.Path); _logger.LogError(ex, "Failed to refresh collections for media item {MediaItem}", mediaItem.Path);
await _localPosterProvider.RefreshPoster(episode); return false;
} }
} }
private async Task RemoveMissingItems(Seq<MediaItem> removedMediaItems) private async Task RefreshSidecarMetadataForItem(MediaItem mediaItem, string path)
{ {
// TODO: flag as missing? delete after some period of time? try
foreach (MediaItem mediaItem in removedMediaItems)
{ {
_logger.LogDebug("Removing missing local media item {MediaItem}", mediaItem.Path); await _localMetadataProvider.RefreshSidecarMetadata(mediaItem, path);
await _mediaItemRepository.Delete(mediaItem.Id); }
catch (Exception ex)
{
_logger.LogError(ex, "Failed to refresh nfo metadata for media item {MediaItem}", mediaItem.Path);
} }
} }
private async Task<bool> RefreshMetadata(MediaItem mediaItem, string ffprobePath) private async Task RefreshFallbackMetadataForItem(MediaItem mediaItem)
{ {
bool durationChange = await _localStatisticsProvider.RefreshStatistics(ffprobePath, mediaItem); try
await _localMetadataProvider.RefreshMetadata(mediaItem); {
bool collectionChange = await _smartCollectionBuilder.RefreshSmartCollections(mediaItem); await _localMetadataProvider.RefreshFallbackMetadata(mediaItem);
return durationChange || collectionChange; }
catch (Exception ex)
{
_logger.LogError(ex, "Failed to refresh fallback metadata for media item {MediaItem}", mediaItem.Path);
}
} }
} }
} }

11
ErsatzTV.Core/Metadata/LocalMediaSourcePlan.cs

@ -0,0 +1,11 @@
using System.Collections.Generic;
using ErsatzTV.Core.Domain;
using LanguageExt;
namespace ErsatzTV.Core.Metadata
{
public record LocalMediaSourcePlan(Either<string, MediaItem> Source, List<ActionPlan> ActionPlans)
{
public Either<string, MediaItem> Source { get; set; } = Source;
}
}

38
ErsatzTV.Core/Metadata/TestMediaScanner.cs → ErsatzTV.Core/Metadata/LocalMediaSourcePlanner.cs

@ -9,14 +9,15 @@ using static LanguageExt.Prelude;
namespace ErsatzTV.Core.Metadata namespace ErsatzTV.Core.Metadata
{ {
public class TestMediaScanner // TODO: this needs a better name
public class LocalMediaSourcePlanner : ILocalMediaSourcePlanner
{ {
private static readonly Seq<string> ImageFileExtensions = Seq("jpg", "jpeg", "png", "gif", "tbn"); private static readonly Seq<string> ImageFileExtensions = Seq("jpg", "jpeg", "png", "gif", "tbn");
private readonly ILocalFileSystem _localFileSystem; private readonly ILocalFileSystem _localFileSystem;
public TestMediaScanner(ILocalFileSystem localFileSystem) => _localFileSystem = localFileSystem; public LocalMediaSourcePlanner(ILocalFileSystem localFileSystem) => _localFileSystem = localFileSystem;
public Seq<LocalMediaItemScanningPlan> DetermineActions( public Seq<LocalMediaSourcePlan> DetermineActions(
MediaType mediaType, MediaType mediaType,
Seq<MediaItem> mediaItems, Seq<MediaItem> mediaItems,
Seq<string> files) Seq<string> files)
@ -32,24 +33,25 @@ namespace ErsatzTV.Core.Metadata
// new files // new files
foreach (string file in newFiles) foreach (string file in newFiles)
{ {
results.Add(file, new ItemScanningPlan(file, ScanningAction.Statistics)); results.Add(file, new ActionPlan(file, ScanningAction.Add));
results.Add(file, new ActionPlan(file, ScanningAction.Statistics));
Option<string> maybeNfoFile = LocateNfoFile(mediaType, files, file); Option<string> maybeNfoFile = LocateNfoFile(mediaType, files, file);
maybeNfoFile.BiIter( maybeNfoFile.BiIter(
nfoFile => nfoFile =>
{ {
results.Add(file, new ItemScanningPlan(nfoFile, ScanningAction.SidecarMetadata)); results.Add(file, new ActionPlan(nfoFile, ScanningAction.SidecarMetadata));
results.Add(file, new ItemScanningPlan(nfoFile, ScanningAction.Collections)); results.Add(file, new ActionPlan(nfoFile, ScanningAction.Collections));
}, },
() => () =>
{ {
results.Add(file, new ItemScanningPlan(file, ScanningAction.FallbackMetadata)); results.Add(file, new ActionPlan(file, ScanningAction.FallbackMetadata));
results.Add(file, new ItemScanningPlan(file, ScanningAction.Collections)); results.Add(file, new ActionPlan(file, ScanningAction.Collections));
}); });
Option<string> maybePoster = LocatePoster(mediaType, files, file); Option<string> maybePoster = LocatePoster(mediaType, files, file);
maybePoster.IfSome( maybePoster.IfSome(
posterFile => results.Add(file, new ItemScanningPlan(posterFile, ScanningAction.Poster))); posterFile => results.Add(file, new ActionPlan(posterFile, ScanningAction.Poster)));
} }
// existing media items // existing media items
@ -57,7 +59,7 @@ namespace ErsatzTV.Core.Metadata
{ {
if ((mediaItem.LastWriteTime ?? DateTime.MinValue) < _localFileSystem.GetLastWriteTime(mediaItem.Path)) if ((mediaItem.LastWriteTime ?? DateTime.MinValue) < _localFileSystem.GetLastWriteTime(mediaItem.Path))
{ {
results.Add(mediaItem, new ItemScanningPlan(mediaItem.Path, ScanningAction.Statistics)); results.Add(mediaItem, new ActionPlan(mediaItem.Path, ScanningAction.Statistics));
} }
Option<string> maybeNfoFile = LocateNfoFile(mediaType, files, mediaItem.Path); Option<string> maybeNfoFile = LocateNfoFile(mediaType, files, mediaItem.Path);
@ -68,8 +70,8 @@ namespace ErsatzTV.Core.Metadata
(mediaItem.Metadata.LastWriteTime ?? DateTime.MinValue) < (mediaItem.Metadata.LastWriteTime ?? DateTime.MinValue) <
_localFileSystem.GetLastWriteTime(nfoFile)) _localFileSystem.GetLastWriteTime(nfoFile))
{ {
results.Add(mediaItem, new ItemScanningPlan(nfoFile, ScanningAction.SidecarMetadata)); results.Add(mediaItem, new ActionPlan(nfoFile, ScanningAction.SidecarMetadata));
results.Add(mediaItem, new ItemScanningPlan(nfoFile, ScanningAction.Collections)); results.Add(mediaItem, new ActionPlan(nfoFile, ScanningAction.Collections));
} }
}); });
@ -81,7 +83,7 @@ namespace ErsatzTV.Core.Metadata
(mediaItem.PosterLastWriteTime ?? DateTime.MinValue) < (mediaItem.PosterLastWriteTime ?? DateTime.MinValue) <
_localFileSystem.GetLastWriteTime(posterFile)) _localFileSystem.GetLastWriteTime(posterFile))
{ {
results.Add(mediaItem, new ItemScanningPlan(posterFile, ScanningAction.Poster)); results.Add(mediaItem, new ActionPlan(posterFile, ScanningAction.Poster));
} }
}); });
} }
@ -89,7 +91,7 @@ namespace ErsatzTV.Core.Metadata
// missing media items // missing media items
foreach (MediaItem mediaItem in mediaItems.Where(i => !files.Contains(i.Path))) foreach (MediaItem mediaItem in mediaItems.Where(i => !files.Contains(i.Path)))
{ {
results.Add(mediaItem, new ItemScanningPlan(mediaItem.Path, ScanningAction.Remove)); results.Add(mediaItem, new ActionPlan(mediaItem.Path, ScanningAction.Remove));
} }
return results.Summarize(); return results.Summarize();
@ -147,15 +149,15 @@ namespace ErsatzTV.Core.Metadata
private class IntermediateResults private class IntermediateResults
{ {
private readonly List<Tuple<Either<string, MediaItem>, ItemScanningPlan>> _rawResults = new(); private readonly List<Tuple<Either<string, MediaItem>, ActionPlan>> _rawResults = new();
public void Add(Either<string, MediaItem> source, ItemScanningPlan plan) => public void Add(Either<string, MediaItem> source, ActionPlan plan) =>
_rawResults.Add(Tuple(source, plan)); _rawResults.Add(Tuple(source, plan));
public Seq<LocalMediaItemScanningPlan> Summarize() => public Seq<LocalMediaSourcePlan> Summarize() =>
_rawResults _rawResults
.GroupBy(t => t.Item1) .GroupBy(t => t.Item1)
.Select(g => new LocalMediaItemScanningPlan(g.Key, g.Select(g2 => g2.Item2).ToList())) .Select(g => new LocalMediaSourcePlan(g.Key, g.Select(g2 => g2.Item2).ToList()))
.ToSeq(); .ToSeq();
} }

17
ErsatzTV.Core/Metadata/LocalMetadataProvider.cs

@ -25,14 +25,15 @@ namespace ErsatzTV.Core.Metadata
_logger = logger; _logger = logger;
} }
public async Task RefreshMetadata(MediaItem mediaItem) public async Task RefreshSidecarMetadata(MediaItem mediaItem, string path)
{ {
Option<MediaMetadata> maybeMetadata = await LoadMetadata(mediaItem); Option<MediaMetadata> maybeMetadata = await LoadMetadata(mediaItem, path);
MediaMetadata metadata = await maybeMetadata.IfSomeAsync(metadata => ApplyMetadataUpdate(mediaItem, metadata));
maybeMetadata.IfNone(() => FallbackMetadataProvider.GetFallbackMetadata(mediaItem));
await ApplyMetadataUpdate(mediaItem, metadata);
} }
public Task RefreshFallbackMetadata(MediaItem mediaItem) =>
ApplyMetadataUpdate(mediaItem, FallbackMetadataProvider.GetFallbackMetadata(mediaItem));
private async Task ApplyMetadataUpdate(MediaItem mediaItem, MediaMetadata metadata) private async Task ApplyMetadataUpdate(MediaItem mediaItem, MediaMetadata metadata)
{ {
if (mediaItem.Metadata == null) if (mediaItem.Metadata == null)
@ -41,6 +42,7 @@ namespace ErsatzTV.Core.Metadata
} }
mediaItem.Metadata.Source = metadata.Source; mediaItem.Metadata.Source = metadata.Source;
mediaItem.Metadata.LastWriteTime = metadata.LastWriteTime;
mediaItem.Metadata.MediaType = metadata.MediaType; mediaItem.Metadata.MediaType = metadata.MediaType;
mediaItem.Metadata.Title = metadata.Title; mediaItem.Metadata.Title = metadata.Title;
mediaItem.Metadata.Subtitle = metadata.Subtitle; mediaItem.Metadata.Subtitle = metadata.Subtitle;
@ -57,9 +59,8 @@ namespace ErsatzTV.Core.Metadata
await _mediaItemRepository.Update(mediaItem); await _mediaItemRepository.Update(mediaItem);
} }
private async Task<Option<MediaMetadata>> LoadMetadata(MediaItem mediaItem) private async Task<Option<MediaMetadata>> LoadMetadata(MediaItem mediaItem, string nfoFileName)
{ {
string nfoFileName = Path.ChangeExtension(mediaItem.Path, "nfo");
if (nfoFileName == null || !File.Exists(nfoFileName)) if (nfoFileName == null || !File.Exists(nfoFileName))
{ {
_logger.LogDebug("NFO file does not exist at {Path}", nfoFileName); _logger.LogDebug("NFO file does not exist at {Path}", nfoFileName);
@ -90,6 +91,7 @@ namespace ErsatzTV.Core.Metadata
nfo => new MediaMetadata nfo => new MediaMetadata
{ {
Source = MetadataSource.Sidecar, Source = MetadataSource.Sidecar,
LastWriteTime = File.GetLastWriteTimeUtc(nfoFileName),
MediaType = MediaType.TvShow, MediaType = MediaType.TvShow,
Title = nfo.ShowTitle, Title = nfo.ShowTitle,
Subtitle = nfo.Title, Subtitle = nfo.Title,
@ -117,6 +119,7 @@ namespace ErsatzTV.Core.Metadata
nfo => new MediaMetadata nfo => new MediaMetadata
{ {
Source = MetadataSource.Sidecar, Source = MetadataSource.Sidecar,
LastWriteTime = File.GetLastWriteTimeUtc(nfoFileName),
MediaType = MediaType.Movie, MediaType = MediaType.Movie,
Title = nfo.Title, Title = nfo.Title,
Description = nfo.Outline, Description = nfo.Outline,

2
ErsatzTV.Core/Metadata/LocalPosterProvider.cs

@ -73,7 +73,7 @@ namespace ErsatzTV.Core.Metadata
return None; return None;
} }
private async Task SavePosterToDisk(MediaItem mediaItem, string posterPath) public async Task SavePosterToDisk(MediaItem mediaItem, string posterPath)
{ {
byte[] originalBytes = await File.ReadAllBytesAsync(posterPath); byte[] originalBytes = await File.ReadAllBytesAsync(posterPath);
Either<BaseError, string> maybeHash = await _imageCache.ResizeAndSaveImage(originalBytes, 220, null); Either<BaseError, string> maybeHash = await _imageCache.ResizeAndSaveImage(originalBytes, 220, null);

13
ErsatzTV.Core/Metadata/ScanningAction.cs

@ -3,11 +3,12 @@
public enum ScanningAction public enum ScanningAction
{ {
None = 0, None = 0,
Statistics = 1, Add = 1,
SidecarMetadata = 2, Remove = 2,
FallbackMetadata = 3, Statistics = 3,
Collections = 4, SidecarMetadata = 4,
Poster = 5, FallbackMetadata = 5,
Remove = 6 Collections = 6,
Poster = 7,
} }
} }

2
ErsatzTV/Services/WorkerService.cs

@ -59,7 +59,7 @@ namespace ErsatzTV.Services
case RefreshMediaItem refreshMediaItem: case RefreshMediaItem refreshMediaItem:
string type = refreshMediaItem switch string type = refreshMediaItem switch
{ {
RefreshMediaItemMetadata => "metadata", // RefreshMediaItemMetadata => "metadata",
RefreshMediaItemStatistics => "statistics", RefreshMediaItemStatistics => "statistics",
RefreshMediaItemCollections => "collections", RefreshMediaItemCollections => "collections",
RefreshMediaItemPoster => "poster", RefreshMediaItemPoster => "poster",

Loading…
Cancel
Save