From 26588154ae6ce1792829adcbe3ee07e4eedb476f Mon Sep 17 00:00:00 2001 From: Jason Dove Date: Mon, 15 Feb 2021 17:55:19 -0600 Subject: [PATCH] rewrite local media scanner (#25) * spike new scanner * add existing items to new scanner * add collection refresh actions * add tv show metadata and posters * update metadata and posters when nfo/poster files are updated * add "remove" action, test for all supported file extensions * update statistics when primary video file is updated * reflect that collections are "sourced" from nfo * implement most scanning actions * cleanup * fix startup * cross-platform scanner tests --- .../Commands/CreateMediaItemHandler.cs | 7 +- .../RefreshMediaItemMetadataHandler.cs | 5 +- .../Metadata/LocalMediaSourcePlannerTests.cs | 1002 +++++++++++++++++ ErsatzTV.Core/Domain/MediaItem.cs | 1 + ErsatzTV.Core/Domain/MediaMetadata.cs | 2 + ErsatzTV.Core/Domain/MetadataSource.cs | 8 + .../Interfaces/Metadata/ILocalFileSystem.cs | 4 +- .../Metadata/ILocalMediaSourcePlanner.cs | 14 + .../Metadata/ILocalMetadataProvider.cs | 3 +- ErsatzTV.Core/Metadata/ActionPlan.cs | 4 + .../Metadata/FallbackMetadataProvider.cs | 2 +- ErsatzTV.Core/Metadata/LocalFileSystem.cs | 3 + ErsatzTV.Core/Metadata/LocalMediaScanner.cs | 262 +++-- .../Metadata/LocalMediaSourcePlan.cs | 11 + .../Metadata/LocalMediaSourcePlanner.cs | 179 +++ .../Metadata/LocalMetadataProvider.cs | 20 +- ErsatzTV.Core/Metadata/LocalPosterProvider.cs | 2 +- ErsatzTV.Core/Metadata/ScanningAction.cs | 14 + ...15153541_MetadataOptimizations.Designer.cs | 905 +++++++++++++++ .../20210215153541_MetadataOptimizations.cs | 44 + .../Migrations/TvContextModelSnapshot.cs | 12 + ErsatzTV/Services/WorkerService.cs | 2 +- ErsatzTV/Shared/LocalMediaSources.razor | 2 +- ErsatzTV/Startup.cs | 1 + 24 files changed, 2413 insertions(+), 96 deletions(-) create mode 100644 ErsatzTV.Core.Tests/Metadata/LocalMediaSourcePlannerTests.cs create mode 100644 ErsatzTV.Core/Domain/MetadataSource.cs create mode 100644 ErsatzTV.Core/Interfaces/Metadata/ILocalMediaSourcePlanner.cs create mode 100644 ErsatzTV.Core/Metadata/ActionPlan.cs create mode 100644 ErsatzTV.Core/Metadata/LocalMediaSourcePlan.cs create mode 100644 ErsatzTV.Core/Metadata/LocalMediaSourcePlanner.cs create mode 100644 ErsatzTV.Core/Metadata/ScanningAction.cs create mode 100644 ErsatzTV.Infrastructure/Migrations/20210215153541_MetadataOptimizations.Designer.cs create mode 100644 ErsatzTV.Infrastructure/Migrations/20210215153541_MetadataOptimizations.cs diff --git a/ErsatzTV.Application/MediaItems/Commands/CreateMediaItemHandler.cs b/ErsatzTV.Application/MediaItems/Commands/CreateMediaItemHandler.cs index 53509737f..f478f58c3 100644 --- a/ErsatzTV.Application/MediaItems/Commands/CreateMediaItemHandler.cs +++ b/ErsatzTV.Application/MediaItems/Commands/CreateMediaItemHandler.cs @@ -52,9 +52,10 @@ namespace ErsatzTV.Application.MediaItems.Commands await _mediaItemRepository.Add(parameters.MediaItem); await _localStatisticsProvider.RefreshStatistics(parameters.FFprobePath, parameters.MediaItem); - await _localMetadataProvider.RefreshMetadata(parameters.MediaItem); - await _localPosterProvider.RefreshPoster(parameters.MediaItem); - await _smartCollectionBuilder.RefreshSmartCollections(parameters.MediaItem); + // TODO: reimplement this + // await _localMetadataProvider.RefreshMetadata(parameters.MediaItem); + // await _localPosterProvider.RefreshPoster(parameters.MediaItem); + // await _smartCollectionBuilder.RefreshSmartCollections(parameters.MediaItem); return ProjectToViewModel(parameters.MediaItem); } diff --git a/ErsatzTV.Application/MediaItems/Commands/RefreshMediaItemMetadataHandler.cs b/ErsatzTV.Application/MediaItems/Commands/RefreshMediaItemMetadataHandler.cs index 9a4a9a0fd..7bc3b695f 100644 --- a/ErsatzTV.Application/MediaItems/Commands/RefreshMediaItemMetadataHandler.cs +++ b/ErsatzTV.Application/MediaItems/Commands/RefreshMediaItemMetadataHandler.cs @@ -46,7 +46,8 @@ namespace ErsatzTV.Application.MediaItems.Commands .Filter(item => File.Exists(item.Path)) .ToValidation($"[Path] '{mediaItem.Path}' does not exist on the file system"); - private Task RefreshMetadata(MediaItem mediaItem) => - _localMetadataProvider.RefreshMetadata(mediaItem).ToUnit(); + private Task RefreshMetadata(MediaItem mediaItem) => Task.CompletedTask.ToUnit(); + // TODO: reimplement this + // _localMetadataProvider.RefreshMetadata(mediaItem).ToUnit(); } } diff --git a/ErsatzTV.Core.Tests/Metadata/LocalMediaSourcePlannerTests.cs b/ErsatzTV.Core.Tests/Metadata/LocalMediaSourcePlannerTests.cs new file mode 100644 index 000000000..24b21e45c --- /dev/null +++ b/ErsatzTV.Core.Tests/Metadata/LocalMediaSourcePlannerTests.cs @@ -0,0 +1,1002 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Runtime.InteropServices; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Interfaces.Metadata; +using ErsatzTV.Core.Metadata; +using FluentAssertions; +using LanguageExt; +using NUnit.Framework; + +namespace ErsatzTV.Core.Tests.Metadata +{ + [TestFixture] + public class LocalMediaSourcePlannerTests + { + private static readonly List VideoFileExtensions = new() + { + "mpg", "mp2", "mpeg", "mpe", "mpv", "ogg", "mp4", + "m4p", "m4v", "avi", "wmv", "mov", "mkv", "ts" + }; + + private static IEnumerable OldEntriesFor(params string[] fileNames) => + fileNames.Map(f => new FakeFileSystemEntry(f, DateTime.MinValue)); + + private static IEnumerable NewEntriesFor(params string[] fileNames) => + fileNames.Map(f => new FakeFileSystemEntry(f, DateTime.MaxValue)); + + private static LocalMediaSourcePlanner ScannerForOldFiles(params string[] fileNames) + => new(new FakeLocalFileSystem(OldEntriesFor(fileNames))); + + private static LocalMediaSourcePlanner ScannerForNewFiles(params string[] fileNames) + => new(new FakeLocalFileSystem(NewEntriesFor(fileNames))); + + private static LocalMediaSourcePlanner ScannerFor(IEnumerable entries) + => new(new FakeLocalFileSystem(entries)); + + private static readonly string FakeRoot = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) + ? "C:\\" + : "/"; + + private static string MovieNameWithExtension(string extension, int year = 2021) => Path.Combine( + FakeRoot, + Path.Combine( + "movies", + Path.Combine($"test ({year})"), + Path.Combine($"test ({year}).{extension}"))); + + private static string MovieNfoName(string nfoFileName) => + Path.Combine(FakeRoot, Path.Combine("movies", Path.Combine("test (2021)"), nfoFileName)); + + private static string MoviePosterNameWithExtension(string basePosterName, string extension) => Path.Combine( + FakeRoot, + Path.Combine( + "movies", + Path.Combine("test (2021)"), + $"{basePosterName}poster.{extension}")); + + private static string EpisodeNameWithExtension(string extension, int episodeNumber = 3) => Path.Combine( + FakeRoot, + Path.Combine( + "tv", + Path.Combine( + "test (2021)", + Path.Combine("season 01", $"test (2021) - s01e{episodeNumber:00}.{extension}")))); + + private static string EpisodeNfoName(int episodeNumber = 3) => + Path.Combine( + FakeRoot, + Path.Combine( + "tv", + Path.Combine( + "test (2021)", + Path.Combine("season 01", $"test (2021) - s01e{episodeNumber:00}.nfo")))); + + private static string SeriesPosterNameWithExtension(string extension) => + Path.Combine(FakeRoot, Path.Combine("tv", Path.Combine("test (2021)", $"poster.{extension}"))); + + [TestFixture] + public class NewMovieTests + { + [Test] + public void WithoutNfo_WithoutPoster( + [ValueSource(typeof(LocalMediaSourcePlannerTests), nameof(VideoFileExtensions))] + string extension) + { + string movieFileName = MovieNameWithExtension(extension); + string[] fileNames = { movieFileName }; + + Seq result = ScannerForOldFiles(fileNames).DetermineActions( + MediaType.Movie, + Seq.Empty, + fileNames.ToSeq()); + + result.Count.Should().Be(1); + (Either source, List itemScanningPlans) = result.Head(); + source.IsLeft.Should().BeTrue(); + source.LeftToSeq().Should().BeEquivalentTo(movieFileName); + itemScanningPlans.Should().BeEquivalentTo( + new ActionPlan(movieFileName, ScanningAction.Add), + new ActionPlan(movieFileName, ScanningAction.Statistics), + new ActionPlan(movieFileName, ScanningAction.FallbackMetadata), + new ActionPlan(movieFileName, ScanningAction.Collections)); + } + + [Test] + public void WithNfo_WithoutPoster( + [ValueSource(typeof(LocalMediaSourcePlannerTests), nameof(VideoFileExtensions))] + string extension, + [Values("test (2021).nfo", "movie.nfo")] + string nfoFile) + { + string movieFileName = MovieNameWithExtension(extension); + string nfoFileName = MovieNfoName(nfoFile); + string[] fileNames = { movieFileName, nfoFileName }; + + Seq result = ScannerForOldFiles(fileNames).DetermineActions( + MediaType.Movie, + Seq.Empty, + fileNames.ToSeq()); + + result.Count.Should().Be(1); + (Either source, List itemScanningPlans) = result.Head(); + source.IsLeft.Should().BeTrue(); + source.LeftToSeq().Should().BeEquivalentTo(movieFileName); + itemScanningPlans.Should().BeEquivalentTo( + new ActionPlan(movieFileName, ScanningAction.Add), + new ActionPlan(movieFileName, ScanningAction.Statistics), + new ActionPlan(nfoFileName, ScanningAction.SidecarMetadata), + new ActionPlan(nfoFileName, ScanningAction.Collections)); + } + + [Test] + public void WithoutNfo_WithPoster( + [ValueSource(typeof(LocalMediaSourcePlannerTests), nameof(VideoFileExtensions))] + string extension, + [Values("", "test (2021)-")] + string basePosterName, + [Values("jpg", "jpeg", "png", "gif", "tbn")] + string posterExtension) + { + string movieFileName = MovieNameWithExtension(extension); + string posterFileName = MoviePosterNameWithExtension(basePosterName, posterExtension); + + string[] fileNames = { movieFileName, posterFileName }; + + Seq result = ScannerForOldFiles(fileNames).DetermineActions( + MediaType.Movie, + Seq.Empty, + fileNames.ToSeq()); + + result.Count.Should().Be(1); + (Either source, List itemScanningPlans) = result.Head(); + source.IsLeft.Should().BeTrue(); + source.LeftToSeq().Should().BeEquivalentTo(movieFileName); + itemScanningPlans.Should().BeEquivalentTo( + new ActionPlan(movieFileName, ScanningAction.Add), + new ActionPlan(movieFileName, ScanningAction.Statistics), + new ActionPlan(movieFileName, ScanningAction.FallbackMetadata), + new ActionPlan(posterFileName, ScanningAction.Poster), + new ActionPlan(movieFileName, ScanningAction.Collections)); + } + + [Test] + public void WithNfo_WithPoster( + [ValueSource(typeof(LocalMediaSourcePlannerTests), nameof(VideoFileExtensions))] + string extension, + [Values("test (2021).nfo", "movie.nfo")] + string nfoFile, + [Values("", "test (2021)-")] + string basePosterName, + [Values("jpg", "jpeg", "png", "gif", "tbn")] + string posterExtension) + { + string movieFileName = MovieNameWithExtension(extension); + string nfoFileName = MovieNfoName(nfoFile); + string posterFileName = MoviePosterNameWithExtension(basePosterName, posterExtension); + + string[] fileNames = { movieFileName, nfoFileName, posterFileName }; + + Seq result = ScannerForOldFiles(fileNames).DetermineActions( + MediaType.Movie, + Seq.Empty, + fileNames.ToSeq()); + + result.Count.Should().Be(1); + (Either source, List itemScanningPlans) = result.Head(); + source.IsLeft.Should().BeTrue(); + source.LeftToSeq().Should().BeEquivalentTo(movieFileName); + itemScanningPlans.Should().BeEquivalentTo( + new ActionPlan(movieFileName, ScanningAction.Add), + new ActionPlan(movieFileName, ScanningAction.Statistics), + new ActionPlan(nfoFileName, ScanningAction.SidecarMetadata), + new ActionPlan(posterFileName, ScanningAction.Poster), + new ActionPlan(nfoFileName, ScanningAction.Collections)); + } + } + + [TestFixture] + public class ExistingMovieTests + { + [Test] + public void Old_File_Should_Do_Nothing( + [ValueSource(typeof(LocalMediaSourcePlannerTests), nameof(VideoFileExtensions))] + string extension) + { + var movieMediaItem = new MediaItem + { + Metadata = new MediaMetadata { Source = MetadataSource.Fallback }, + Path = MovieNameWithExtension(extension) + }; + + string[] fileNames = { movieMediaItem.Path }; + + Seq result = ScannerForOldFiles(fileNames).DetermineActions( + MediaType.Movie, + Seq.create(movieMediaItem), + fileNames.ToSeq()); + + result.Count.Should().Be(0); + } + + [Test] + public void Updated_File_Should_Refresh_Statistics( + [ValueSource(typeof(LocalMediaSourcePlannerTests), nameof(VideoFileExtensions))] + string extension) + { + var movieMediaItem = new MediaItem + { + Metadata = new MediaMetadata { Source = MetadataSource.Fallback }, + Path = MovieNameWithExtension(extension) + }; + + string[] fileNames = { movieMediaItem.Path }; + + Seq result = ScannerForNewFiles(fileNames).DetermineActions( + MediaType.Movie, + Seq.create(movieMediaItem), + fileNames.ToSeq()); + + result.Count.Should().Be(1); + (Either source, List itemScanningPlans) = result.Head(); + source.IsRight.Should().BeTrue(); + source.RightToSeq().Should().BeEquivalentTo(movieMediaItem); + itemScanningPlans.Should().BeEquivalentTo( + new ActionPlan(movieMediaItem.Path, ScanningAction.Statistics)); + } + + [Test] + public void Fallback_WithNewNfo_WithoutPoster( + [ValueSource(typeof(LocalMediaSourcePlannerTests), nameof(VideoFileExtensions))] + string extension, + [Values("test (2021).nfo", "movie.nfo")] + string nfoFile) + { + var movieMediaItem = new MediaItem + { + Metadata = new MediaMetadata { Source = MetadataSource.Fallback }, + Path = MovieNameWithExtension(extension) + }; + + string nfoFileName = MovieNfoName(nfoFile); + string[] fileNames = { movieMediaItem.Path, nfoFileName }; + + Seq result = ScannerForOldFiles(fileNames).DetermineActions( + MediaType.Movie, + Seq.create(movieMediaItem), + fileNames.ToSeq()); + + result.Count.Should().Be(1); + (Either source, List itemScanningPlans) = result.Head(); + source.IsRight.Should().BeTrue(); + source.RightToSeq().Should().BeEquivalentTo(movieMediaItem); + itemScanningPlans.Should().BeEquivalentTo( + new ActionPlan(nfoFileName, ScanningAction.SidecarMetadata), + new ActionPlan(nfoFileName, ScanningAction.Collections)); + } + + [Test] + public void Sidecar_WithOldNfo_WithoutPoster( + [ValueSource(typeof(LocalMediaSourcePlannerTests), nameof(VideoFileExtensions))] + string extension, + [Values("test (2021).nfo", "movie.nfo")] + string nfoFile) + { + var movieMediaItem = new MediaItem + { + Metadata = new MediaMetadata { Source = MetadataSource.Sidecar }, + Path = MovieNameWithExtension(extension) + }; + + string nfoFileName = MovieNfoName(nfoFile); + string[] fileNames = { movieMediaItem.Path, nfoFileName }; + + Seq result = ScannerForOldFiles(fileNames).DetermineActions( + MediaType.Movie, + Seq.create(movieMediaItem), + fileNames.ToSeq()); + + result.Count.Should().Be(0); + } + + [Test] + public void Sidecar_WithUpdatedNfo_WithoutPoster( + [ValueSource(typeof(LocalMediaSourcePlannerTests), nameof(VideoFileExtensions))] + string extension, + [Values("test (2021).nfo", "movie.nfo")] + string nfoFile) + { + var movieMediaItem = new MediaItem + { + Metadata = new MediaMetadata { Source = MetadataSource.Sidecar }, + Path = MovieNameWithExtension(extension) + }; + + string nfoFileName = MovieNfoName(nfoFile); + string[] fileNames = { movieMediaItem.Path, nfoFileName }; + + Seq result = + ScannerFor(OldEntriesFor(movieMediaItem.Path).Concat(NewEntriesFor(nfoFileName))) + .DetermineActions( + MediaType.Movie, + Seq.create(movieMediaItem), + fileNames.ToSeq()); + + result.Count.Should().Be(1); + (Either source, List itemScanningPlans) = result.Head(); + source.IsRight.Should().BeTrue(); + source.RightToSeq().Should().BeEquivalentTo(movieMediaItem); + itemScanningPlans.Should().BeEquivalentTo( + new ActionPlan(nfoFileName, ScanningAction.SidecarMetadata), + new ActionPlan(nfoFileName, ScanningAction.Collections)); + } + + [Test] + public void WithoutNfo_WithNewPoster( + [ValueSource(typeof(LocalMediaSourcePlannerTests), nameof(VideoFileExtensions))] + string extension, + [Values("", "test (2021)-")] + string basePosterName, + [Values("jpg", "jpeg", "png", "gif", "tbn")] + string posterExtension) + { + var movieMediaItem = new MediaItem + { + Metadata = new MediaMetadata { Source = MetadataSource.Fallback }, + Path = MovieNameWithExtension(extension) + }; + + string posterFileName = MoviePosterNameWithExtension(basePosterName, posterExtension); + string[] fileNames = { movieMediaItem.Path, posterFileName }; + + Seq result = ScannerForOldFiles(fileNames).DetermineActions( + MediaType.Movie, + Seq.create(movieMediaItem), + fileNames.ToSeq()); + + result.Count.Should().Be(1); + (Either source, List itemScanningPlans) = result.Head(); + source.IsRight.Should().BeTrue(); + source.RightToSeq().Should().BeEquivalentTo(movieMediaItem); + itemScanningPlans.Should() + .BeEquivalentTo(new ActionPlan(posterFileName, ScanningAction.Poster)); + } + + [Test] + public void WithoutNfo_WithOldPoster( + [ValueSource(typeof(LocalMediaSourcePlannerTests), nameof(VideoFileExtensions))] + string extension, + [Values("", "test (2021)-")] + string basePosterName, + [Values("jpg", "jpeg", "png", "gif", "tbn")] + string posterExtension) + { + var movieMediaItem = new MediaItem + { + Metadata = new MediaMetadata { Source = MetadataSource.Fallback }, + Path = MovieNameWithExtension(extension), + Poster = "anything", + PosterLastWriteTime = DateTime.UtcNow + }; + + string posterFileName = MoviePosterNameWithExtension(basePosterName, posterExtension); + string[] fileNames = { movieMediaItem.Path, posterFileName }; + + Seq result = ScannerForOldFiles(fileNames).DetermineActions( + MediaType.Movie, + Seq.create(movieMediaItem), + fileNames.ToSeq()); + + result.Count.Should().Be(0); + } + + [Test] + public void WithoutNfo_WithUpdatedPoster( + [ValueSource(typeof(LocalMediaSourcePlannerTests), nameof(VideoFileExtensions))] + string extension, + [Values("", "test (2021)-")] + string basePosterName, + [Values("jpg", "jpeg", "png", "gif", "tbn")] + string posterExtension) + { + var movieMediaItem = new MediaItem + { + Metadata = new MediaMetadata { Source = MetadataSource.Fallback }, + Path = MovieNameWithExtension(extension), + Poster = "anything", + PosterLastWriteTime = DateTime.UtcNow + }; + + string posterFileName = MoviePosterNameWithExtension(basePosterName, posterExtension); + string[] fileNames = { movieMediaItem.Path, posterFileName }; + + Seq result = + ScannerFor(OldEntriesFor(movieMediaItem.Path).Concat(NewEntriesFor(posterFileName))) + .DetermineActions( + MediaType.Movie, + Seq.create(movieMediaItem), + fileNames.ToSeq()); + + result.Count.Should().Be(1); + (Either source, List itemScanningPlans) = result.Head(); + source.IsRight.Should().BeTrue(); + source.RightToSeq().Should().BeEquivalentTo(movieMediaItem); + itemScanningPlans.Should() + .BeEquivalentTo(new ActionPlan(posterFileName, ScanningAction.Poster)); + } + + [Test] + public void WithNewNfo_WithNewPoster( + [ValueSource(typeof(LocalMediaSourcePlannerTests), nameof(VideoFileExtensions))] + string extension, + [Values("test (2021).nfo", "movie.nfo")] + string nfoFile, + [Values("", "test (2021)-")] + string basePosterName, + [Values("jpg", "jpeg", "png", "gif", "tbn")] + string posterExtension) + { + var movieMediaItem = new MediaItem + { + Metadata = new MediaMetadata { Source = MetadataSource.Fallback }, + Path = MovieNameWithExtension(extension) + }; + + string nfoFileName = MovieNfoName(nfoFile); + string posterFileName = MoviePosterNameWithExtension(basePosterName, posterExtension); + string[] fileNames = { movieMediaItem.Path, nfoFileName, posterFileName }; + + Seq result = ScannerForOldFiles(fileNames).DetermineActions( + MediaType.Movie, + Seq.create(movieMediaItem), + fileNames.ToSeq()); + + result.Count.Should().Be(1); + (Either source, List itemScanningPlans) = result.Head(); + source.IsRight.Should().BeTrue(); + source.RightToSeq().Should().BeEquivalentTo(movieMediaItem); + itemScanningPlans.Should().BeEquivalentTo( + new ActionPlan(nfoFileName, ScanningAction.SidecarMetadata), + new ActionPlan(posterFileName, ScanningAction.Poster), + new ActionPlan(nfoFileName, ScanningAction.Collections)); + } + } + + [TestFixture] + public class NewEpisodeTests + { + [Test] + public void WithoutNfo_WithoutPoster( + [ValueSource(typeof(LocalMediaSourcePlannerTests), nameof(VideoFileExtensions))] + string extension) + { + string episodeFileName = EpisodeNameWithExtension(extension); + string[] fileNames = { episodeFileName }; + + Seq result = ScannerForOldFiles(fileNames).DetermineActions( + MediaType.TvShow, + Seq.Empty, + fileNames.ToSeq()); + + result.Count.Should().Be(1); + (Either source, List itemScanningPlans) = result.Head(); + source.IsLeft.Should().BeTrue(); + source.LeftToSeq().Should().BeEquivalentTo(episodeFileName); + itemScanningPlans.Should().BeEquivalentTo( + new ActionPlan(episodeFileName, ScanningAction.Add), + new ActionPlan(episodeFileName, ScanningAction.Statistics), + new ActionPlan(episodeFileName, ScanningAction.FallbackMetadata), + new ActionPlan(episodeFileName, ScanningAction.Collections)); + } + + [Test] + public void WithNfo_WithoutPoster( + [ValueSource(typeof(LocalMediaSourcePlannerTests), nameof(VideoFileExtensions))] + string extension) + { + string episodeFileName = EpisodeNameWithExtension(extension); + string nfoFileName = EpisodeNfoName(); + string[] fileNames = { episodeFileName, nfoFileName }; + + Seq result = ScannerForOldFiles(fileNames).DetermineActions( + MediaType.TvShow, + Seq.Empty, + fileNames.ToSeq()); + + result.Count.Should().Be(1); + (Either source, List itemScanningPlans) = result.Head(); + source.IsLeft.Should().BeTrue(); + source.LeftToSeq().Should().BeEquivalentTo(episodeFileName); + itemScanningPlans.Should().BeEquivalentTo( + new ActionPlan(episodeFileName, ScanningAction.Add), + new ActionPlan(episodeFileName, ScanningAction.Statistics), + new ActionPlan(nfoFileName, ScanningAction.SidecarMetadata), + new ActionPlan(nfoFileName, ScanningAction.Collections)); + } + + [Test] + public void WithoutNfo_WithPoster( + [ValueSource(typeof(LocalMediaSourcePlannerTests), nameof(VideoFileExtensions))] + string extension, + [Values("jpg", "jpeg", "png", "gif", "tbn")] + string posterExtension) + { + string episodeFileName = EpisodeNameWithExtension(extension); + string posterFileName = SeriesPosterNameWithExtension(posterExtension); + + string[] fileNames = { episodeFileName, posterFileName }; + + Seq result = ScannerForOldFiles(fileNames).DetermineActions( + MediaType.TvShow, + Seq.Empty, + fileNames.ToSeq()); + + result.Count.Should().Be(1); + (Either source, List itemScanningPlans) = result.Head(); + source.IsLeft.Should().BeTrue(); + source.LeftToSeq().Should().BeEquivalentTo(episodeFileName); + itemScanningPlans.Should().BeEquivalentTo( + new ActionPlan(episodeFileName, ScanningAction.Add), + new ActionPlan(episodeFileName, ScanningAction.Statistics), + new ActionPlan(episodeFileName, ScanningAction.FallbackMetadata), + new ActionPlan(posterFileName, ScanningAction.Poster), + new ActionPlan(episodeFileName, ScanningAction.Collections)); + } + + [Test] + public void WithNfo_WithPoster( + [ValueSource(typeof(LocalMediaSourcePlannerTests), nameof(VideoFileExtensions))] + string extension, + [Values("jpg", "jpeg", "png", "gif", "tbn")] + string posterExtension) + { + string episodeFileName = EpisodeNameWithExtension(extension); + string nfoFileName = EpisodeNfoName(); + string posterFileName = SeriesPosterNameWithExtension(posterExtension); + + string[] fileNames = { episodeFileName, nfoFileName, posterFileName }; + + Seq result = ScannerForOldFiles(fileNames).DetermineActions( + MediaType.TvShow, + Seq.Empty, + fileNames.ToSeq()); + + result.Count.Should().Be(1); + (Either source, List itemScanningPlans) = result.Head(); + source.IsLeft.Should().BeTrue(); + source.LeftToSeq().Should().BeEquivalentTo(episodeFileName); + itemScanningPlans.Should().BeEquivalentTo( + new ActionPlan(episodeFileName, ScanningAction.Add), + new ActionPlan(episodeFileName, ScanningAction.Statistics), + new ActionPlan(nfoFileName, ScanningAction.SidecarMetadata), + new ActionPlan(posterFileName, ScanningAction.Poster), + new ActionPlan(nfoFileName, ScanningAction.Collections)); + } + } + + [TestFixture] + public class ExistingEpisodeTests + { + [Test] + public void Old_File_Should_Do_Nothing( + [ValueSource(typeof(LocalMediaSourcePlannerTests), nameof(VideoFileExtensions))] + string extension) + { + var episodeMediaItem = new MediaItem + { + Metadata = new MediaMetadata { Source = MetadataSource.Fallback }, + Path = EpisodeNameWithExtension(extension) + }; + + string[] fileNames = { episodeMediaItem.Path }; + + Seq result = ScannerForOldFiles(fileNames).DetermineActions( + MediaType.TvShow, + Seq.create(episodeMediaItem), + fileNames.ToSeq()); + + result.Count.Should().Be(0); + } + + [Test] + public void Updated_File_Should_Refresh_Statistics( + [ValueSource(typeof(LocalMediaSourcePlannerTests), nameof(VideoFileExtensions))] + string extension) + { + var episodeMediaItem = new MediaItem + { + Metadata = new MediaMetadata { Source = MetadataSource.Fallback }, + Path = EpisodeNameWithExtension(extension) + }; + + string[] fileNames = { episodeMediaItem.Path }; + + Seq result = ScannerForNewFiles(fileNames).DetermineActions( + MediaType.TvShow, + Seq.create(episodeMediaItem), + fileNames.ToSeq()); + + result.Count.Should().Be(1); + (Either source, List itemScanningPlans) = result.Head(); + source.IsRight.Should().BeTrue(); + source.RightToSeq().Should().BeEquivalentTo(episodeMediaItem); + itemScanningPlans.Should().BeEquivalentTo( + new ActionPlan(episodeMediaItem.Path, ScanningAction.Statistics)); + } + + [Test] + public void Fallback_WithNewNfo_WithoutPoster( + [ValueSource(typeof(LocalMediaSourcePlannerTests), nameof(VideoFileExtensions))] + string extension) + { + var episodeMediaItem = new MediaItem + { + Metadata = new MediaMetadata { Source = MetadataSource.Fallback }, + Path = EpisodeNameWithExtension(extension) + }; + + string nfoFileName = EpisodeNfoName(); + string[] fileNames = { episodeMediaItem.Path, nfoFileName }; + + Seq result = ScannerForOldFiles(fileNames).DetermineActions( + MediaType.TvShow, + Seq.create(episodeMediaItem), + fileNames.ToSeq()); + + result.Count.Should().Be(1); + (Either source, List itemScanningPlans) = result.Head(); + source.IsRight.Should().BeTrue(); + source.RightToSeq().Should().BeEquivalentTo(episodeMediaItem); + itemScanningPlans.Should().BeEquivalentTo( + new ActionPlan(nfoFileName, ScanningAction.SidecarMetadata), + new ActionPlan(nfoFileName, ScanningAction.Collections)); + } + + [Test] + public void Sidecar_WithOldNfo_WithoutPoster( + [ValueSource(typeof(LocalMediaSourcePlannerTests), nameof(VideoFileExtensions))] + string extension) + { + var episodeMediaItem = new MediaItem + { + Metadata = new MediaMetadata { Source = MetadataSource.Sidecar }, + Path = EpisodeNameWithExtension(extension) + }; + + string nfoFileName = EpisodeNfoName(); + string[] fileNames = { episodeMediaItem.Path, nfoFileName }; + + Seq result = ScannerForOldFiles(fileNames).DetermineActions( + MediaType.TvShow, + Seq.create(episodeMediaItem), + fileNames.ToSeq()); + + result.Count.Should().Be(0); + } + + [Test] + public void Sidecar_WithUpdatedNfo_WithoutPoster( + [ValueSource(typeof(LocalMediaSourcePlannerTests), nameof(VideoFileExtensions))] + string extension) + { + var episodeMediaItem = new MediaItem + { + Metadata = new MediaMetadata { Source = MetadataSource.Sidecar }, + Path = EpisodeNameWithExtension(extension) + }; + + string nfoFileName = EpisodeNfoName(); + string[] fileNames = { episodeMediaItem.Path, nfoFileName }; + + Seq result = + ScannerFor(OldEntriesFor(episodeMediaItem.Path).Concat(NewEntriesFor(nfoFileName))) + .DetermineActions( + MediaType.TvShow, + Seq.create(episodeMediaItem), + fileNames.ToSeq()); + + result.Count.Should().Be(1); + (Either source, List itemScanningPlans) = result.Head(); + source.IsRight.Should().BeTrue(); + source.RightToSeq().Should().BeEquivalentTo(episodeMediaItem); + itemScanningPlans.Should().BeEquivalentTo( + new ActionPlan(nfoFileName, ScanningAction.SidecarMetadata), + new ActionPlan(nfoFileName, ScanningAction.Collections)); + } + + [Test] + public void WithoutNfo_WithNewPoster( + [ValueSource(typeof(LocalMediaSourcePlannerTests), nameof(VideoFileExtensions))] + string extension, + [Values("jpg", "jpeg", "png", "gif", "tbn")] + string posterExtension) + { + var episodeMediaItem = new MediaItem + { + Metadata = new MediaMetadata { Source = MetadataSource.Fallback }, + Path = EpisodeNameWithExtension(extension) + }; + + string posterFileName = SeriesPosterNameWithExtension(posterExtension); + string[] fileNames = { episodeMediaItem.Path, posterFileName }; + + Seq result = ScannerForOldFiles(fileNames).DetermineActions( + MediaType.TvShow, + Seq.create(episodeMediaItem), + fileNames.ToSeq()); + + result.Count.Should().Be(1); + (Either source, List itemScanningPlans) = result.Head(); + source.IsRight.Should().BeTrue(); + source.RightToSeq().Should().BeEquivalentTo(episodeMediaItem); + itemScanningPlans.Should() + .BeEquivalentTo(new ActionPlan(posterFileName, ScanningAction.Poster)); + } + + [Test] + public void WithoutNfo_WithOldPoster( + [ValueSource(typeof(LocalMediaSourcePlannerTests), nameof(VideoFileExtensions))] + string extension, + [Values("jpg", "jpeg", "png", "gif", "tbn")] + string posterExtension) + { + var episodeMediaItem = new MediaItem + { + Metadata = new MediaMetadata { Source = MetadataSource.Fallback }, + Path = EpisodeNameWithExtension(extension), + Poster = "anything", + PosterLastWriteTime = DateTime.UtcNow + }; + + string posterFileName = SeriesPosterNameWithExtension(posterExtension); + string[] fileNames = { episodeMediaItem.Path, posterFileName }; + + Seq result = ScannerForOldFiles(fileNames).DetermineActions( + MediaType.TvShow, + Seq.create(episodeMediaItem), + fileNames.ToSeq()); + + result.Count.Should().Be(0); + } + + [Test] + public void WithoutNfo_WithUpdatedPoster( + [ValueSource(typeof(LocalMediaSourcePlannerTests), nameof(VideoFileExtensions))] + string extension, + [Values("jpg", "jpeg", "png", "gif", "tbn")] + string posterExtension) + { + var episodeMediaItem = new MediaItem + { + Metadata = new MediaMetadata { Source = MetadataSource.Fallback }, + Path = EpisodeNameWithExtension(extension), + Poster = "anything", + PosterLastWriteTime = DateTime.UtcNow + }; + + string posterFileName = SeriesPosterNameWithExtension(posterExtension); + string[] fileNames = { episodeMediaItem.Path, posterFileName }; + + Seq result = + ScannerFor(OldEntriesFor(episodeMediaItem.Path).Concat(NewEntriesFor(posterFileName))) + .DetermineActions( + MediaType.TvShow, + Seq.create(episodeMediaItem), + fileNames.ToSeq()); + + result.Count.Should().Be(1); + (Either source, List itemScanningPlans) = result.Head(); + source.IsRight.Should().BeTrue(); + source.RightToSeq().Should().BeEquivalentTo(episodeMediaItem); + itemScanningPlans.Should() + .BeEquivalentTo(new ActionPlan(posterFileName, ScanningAction.Poster)); + } + + [Test] + public void WithNewNfo_WithNewPoster( + [ValueSource(typeof(LocalMediaSourcePlannerTests), nameof(VideoFileExtensions))] + string extension, + [Values("jpg", "jpeg", "png", "gif", "tbn")] + string posterExtension) + { + var episodeMediaItem = new MediaItem + { + Metadata = new MediaMetadata { Source = MetadataSource.Fallback }, + Path = EpisodeNameWithExtension(extension) + }; + + string nfoFileName = EpisodeNfoName(); + string posterFileName = SeriesPosterNameWithExtension(posterExtension); + string[] fileNames = { episodeMediaItem.Path, nfoFileName, posterFileName }; + + Seq result = ScannerForOldFiles(fileNames).DetermineActions( + MediaType.TvShow, + Seq.create(episodeMediaItem), + fileNames.ToSeq()); + + result.Count.Should().Be(1); + (Either source, List itemScanningPlans) = result.Head(); + source.IsRight.Should().BeTrue(); + source.RightToSeq().Should().BeEquivalentTo(episodeMediaItem); + itemScanningPlans.Should().BeEquivalentTo( + new ActionPlan(nfoFileName, ScanningAction.SidecarMetadata), + new ActionPlan(posterFileName, ScanningAction.Poster), + new ActionPlan(nfoFileName, ScanningAction.Collections)); + } + } + + [Test] + public void Movies_Should_Ignore_ExtraFolders( + [Values( + "Behind The Scenes", + "Deleted Scenes", + "Featurettes", + "Interviews", + "Scenes", + "Shorts", + "Trailers", + "Other", + "Extras", + "Specials")] + string folder, + [ValueSource(typeof(LocalMediaSourcePlannerTests), nameof(VideoFileExtensions))] + string extension) + { + string[] fileNames = + { + Path.Combine( + FakeRoot, + Path.Combine( + "movies", + Path.Combine("test (2021)", Path.Combine(folder, $"test (2021).{extension}")))) + }; + + Seq result = ScannerForOldFiles(fileNames).DetermineActions( + MediaType.Movie, + Seq.Empty, + fileNames.ToSeq()); + + result.Count.Should().Be(0); + } + + [Test] + public void Movies_Should_Ignore_ExtraFiles( + [Values( + "behindthescenes", + "deleted", + "featurette", + "interview", + "scene", + "short", + "trailer", + "other")] + string extra, + [ValueSource(typeof(LocalMediaSourcePlannerTests), nameof(VideoFileExtensions))] + string extension) + { + string[] fileNames = + { + Path.Combine( + FakeRoot, + Path.Combine( + "movies", + Path.Combine("test (2021)", $"test (2021)-{extra}.{extension}"))) + }; + + Seq result = ScannerForOldFiles(fileNames).DetermineActions( + MediaType.Movie, + Seq.Empty, + fileNames.ToSeq()); + + result.Count.Should().Be(0); + } + + [Test] + public void Movies_Should_Remove_Missing_MediaItems( + [ValueSource(typeof(LocalMediaSourcePlannerTests), nameof(VideoFileExtensions))] + string extension) + { + var movieMediaItem = new MediaItem + { + Metadata = new MediaMetadata { Source = MetadataSource.Fallback }, + Path = MovieNameWithExtension(extension) + }; + + var movieMediaItem2 = new MediaItem + { + Metadata = new MediaMetadata { Source = MetadataSource.Fallback }, + Path = MovieNameWithExtension(extension, 2022) + }; + + string[] fileNames = { "anything" }; + + Seq result = ScannerForOldFiles(fileNames).DetermineActions( + MediaType.Movie, + Seq.create(movieMediaItem, movieMediaItem2), + fileNames.ToSeq()); + + result.Count.Should().Be(2); + + (Either source1, List itemScanningPlans1) = result.Head(); + source1.IsRight.Should().BeTrue(); + source1.RightToSeq().Should().BeEquivalentTo(movieMediaItem); + itemScanningPlans1.Should().BeEquivalentTo( + new ActionPlan(movieMediaItem.Path, ScanningAction.Remove)); + + (Either source2, List itemScanningPlans2) = result.Last(); + source2.IsRight.Should().BeTrue(); + source2.RightToSeq().Should().BeEquivalentTo(movieMediaItem2); + itemScanningPlans2.Should().BeEquivalentTo( + new ActionPlan(movieMediaItem2.Path, ScanningAction.Remove)); + } + + [Test] + public void Episodes_Should_Remove_Missing_MediaItems( + [ValueSource(typeof(LocalMediaSourcePlannerTests), nameof(VideoFileExtensions))] + string extension) + { + var episodeMediaItem = new MediaItem + { + Metadata = new MediaMetadata { Source = MetadataSource.Fallback }, + Path = EpisodeNameWithExtension(extension) + }; + + var episodeMediaItem2 = new MediaItem + { + Metadata = new MediaMetadata { Source = MetadataSource.Fallback }, + Path = EpisodeNameWithExtension(extension, 4) + }; + + string[] fileNames = { "anything" }; + + Seq result = ScannerForOldFiles(fileNames).DetermineActions( + MediaType.TvShow, + Seq.create(episodeMediaItem, episodeMediaItem2), + fileNames.ToSeq()); + + result.Count.Should().Be(2); + + (Either source1, List itemScanningPlans1) = result.Head(); + source1.IsRight.Should().BeTrue(); + source1.RightToSeq().Should().BeEquivalentTo(episodeMediaItem); + itemScanningPlans1.Should().BeEquivalentTo( + new ActionPlan(episodeMediaItem.Path, ScanningAction.Remove)); + + (Either source2, List itemScanningPlans2) = result.Last(); + source2.IsRight.Should().BeTrue(); + source2.RightToSeq().Should().BeEquivalentTo(episodeMediaItem2); + itemScanningPlans2.Should().BeEquivalentTo( + new ActionPlan(episodeMediaItem2.Path, ScanningAction.Remove)); + } + + private class FakeLocalFileSystem : ILocalFileSystem + { + private readonly Dictionary _files = new(); + + public FakeLocalFileSystem(IEnumerable entries) + { + foreach ((string path, DateTime modifyTime) in entries) + { + _files.Add(path, modifyTime); + } + } + + public DateTime GetLastWriteTime(string path) => + _files.ContainsKey(path) ? _files[path] : DateTime.MinValue; + + public bool IsMediaSourceAccessible(LocalMediaSource localMediaSource) => throw new NotSupportedException(); + + public Seq FindRelevantVideos(LocalMediaSource localMediaSource) => + throw new NotSupportedException(); + + public bool ShouldRefreshMetadata(LocalMediaSource localMediaSource, MediaItem mediaItem) => + throw new NotSupportedException(); + + public bool ShouldRefreshPoster(MediaItem mediaItem) => throw new NotSupportedException(); + } + + private record FakeFileSystemEntry(string Path, DateTime ModifyTime); + } +} diff --git a/ErsatzTV.Core/Domain/MediaItem.cs b/ErsatzTV.Core/Domain/MediaItem.cs index d5e8fd7b7..5136d3b8e 100644 --- a/ErsatzTV.Core/Domain/MediaItem.cs +++ b/ErsatzTV.Core/Domain/MediaItem.cs @@ -10,6 +10,7 @@ namespace ErsatzTV.Core.Domain public MediaSource Source { get; set; } public string Path { get; set; } public string Poster { get; set; } + public DateTime? PosterLastWriteTime { get; set; } public MediaMetadata Metadata { get; set; } public DateTime? LastWriteTime { get; set; } public IList SimpleMediaCollections { get; set; } diff --git a/ErsatzTV.Core/Domain/MediaMetadata.cs b/ErsatzTV.Core/Domain/MediaMetadata.cs index efe2774bd..7e725d0bb 100644 --- a/ErsatzTV.Core/Domain/MediaMetadata.cs +++ b/ErsatzTV.Core/Domain/MediaMetadata.cs @@ -5,6 +5,8 @@ namespace ErsatzTV.Core.Domain { public record MediaMetadata : IDisplaySize { + public MetadataSource Source { get; set; } + public DateTime? LastWriteTime { get; set; } public TimeSpan Duration { get; set; } public string SampleAspectRatio { get; set; } public string DisplayAspectRatio { get; set; } diff --git a/ErsatzTV.Core/Domain/MetadataSource.cs b/ErsatzTV.Core/Domain/MetadataSource.cs new file mode 100644 index 000000000..3cb073041 --- /dev/null +++ b/ErsatzTV.Core/Domain/MetadataSource.cs @@ -0,0 +1,8 @@ +namespace ErsatzTV.Core.Domain +{ + public enum MetadataSource + { + Fallback = 0, + Sidecar = 1 + } +} diff --git a/ErsatzTV.Core/Interfaces/Metadata/ILocalFileSystem.cs b/ErsatzTV.Core/Interfaces/Metadata/ILocalFileSystem.cs index 10de3a11a..d2d0bb575 100644 --- a/ErsatzTV.Core/Interfaces/Metadata/ILocalFileSystem.cs +++ b/ErsatzTV.Core/Interfaces/Metadata/ILocalFileSystem.cs @@ -1,10 +1,12 @@ -using ErsatzTV.Core.Domain; +using System; +using ErsatzTV.Core.Domain; using LanguageExt; namespace ErsatzTV.Core.Interfaces.Metadata { public interface ILocalFileSystem { + public DateTime GetLastWriteTime(string path); public bool IsMediaSourceAccessible(LocalMediaSource localMediaSource); public Seq FindRelevantVideos(LocalMediaSource localMediaSource); public bool ShouldRefreshMetadata(LocalMediaSource localMediaSource, MediaItem mediaItem); diff --git a/ErsatzTV.Core/Interfaces/Metadata/ILocalMediaSourcePlanner.cs b/ErsatzTV.Core/Interfaces/Metadata/ILocalMediaSourcePlanner.cs new file mode 100644 index 000000000..555463b53 --- /dev/null +++ b/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 DetermineActions( + MediaType mediaType, + Seq mediaItems, + Seq files); + } +} diff --git a/ErsatzTV.Core/Interfaces/Metadata/ILocalMetadataProvider.cs b/ErsatzTV.Core/Interfaces/Metadata/ILocalMetadataProvider.cs index eea22c7af..8e413e1a4 100644 --- a/ErsatzTV.Core/Interfaces/Metadata/ILocalMetadataProvider.cs +++ b/ErsatzTV.Core/Interfaces/Metadata/ILocalMetadataProvider.cs @@ -5,6 +5,7 @@ namespace ErsatzTV.Core.Interfaces.Metadata { public interface ILocalMetadataProvider { - Task RefreshMetadata(MediaItem mediaItem); + Task RefreshSidecarMetadata(MediaItem mediaItem, string path); + Task RefreshFallbackMetadata(MediaItem mediaItem); } } diff --git a/ErsatzTV.Core/Metadata/ActionPlan.cs b/ErsatzTV.Core/Metadata/ActionPlan.cs new file mode 100644 index 000000000..b10ab95fd --- /dev/null +++ b/ErsatzTV.Core/Metadata/ActionPlan.cs @@ -0,0 +1,4 @@ +namespace ErsatzTV.Core.Metadata +{ + public record ActionPlan(string TargetPath, ScanningAction TargetAction); +} diff --git a/ErsatzTV.Core/Metadata/FallbackMetadataProvider.cs b/ErsatzTV.Core/Metadata/FallbackMetadataProvider.cs index 0723ec7fd..5320ad3b0 100644 --- a/ErsatzTV.Core/Metadata/FallbackMetadataProvider.cs +++ b/ErsatzTV.Core/Metadata/FallbackMetadataProvider.cs @@ -10,7 +10,7 @@ namespace ErsatzTV.Core.Metadata public static MediaMetadata GetFallbackMetadata(MediaItem mediaItem) { string fileName = Path.GetFileName(mediaItem.Path); - var metadata = new MediaMetadata { Title = fileName ?? mediaItem.Path }; + var metadata = new MediaMetadata { Source = MetadataSource.Fallback, Title = fileName ?? mediaItem.Path }; if (fileName != null) { diff --git a/ErsatzTV.Core/Metadata/LocalFileSystem.cs b/ErsatzTV.Core/Metadata/LocalFileSystem.cs index 74153e6bb..bb353a3fc 100644 --- a/ErsatzTV.Core/Metadata/LocalFileSystem.cs +++ b/ErsatzTV.Core/Metadata/LocalFileSystem.cs @@ -10,6 +10,9 @@ namespace ErsatzTV.Core.Metadata { public class LocalFileSystem : ILocalFileSystem { + public DateTime GetLastWriteTime(string path) => + Try(File.GetLastWriteTimeUtc(path)).IfFail(() => DateTime.MinValue); + public bool IsMediaSourceAccessible(LocalMediaSource localMediaSource) => Directory.Exists(localMediaSource.Folder); diff --git a/ErsatzTV.Core/Metadata/LocalMediaScanner.cs b/ErsatzTV.Core/Metadata/LocalMediaScanner.cs index 448324924..b07a042fe 100644 --- a/ErsatzTV.Core/Metadata/LocalMediaScanner.cs +++ b/ErsatzTV.Core/Metadata/LocalMediaScanner.cs @@ -1,22 +1,26 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using System.IO; using System.Linq; using System.Threading.Tasks; using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Interfaces.Images; using ErsatzTV.Core.Interfaces.Metadata; using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Interfaces.Scheduling; using LanguageExt; using Microsoft.Extensions.Logging; using static LanguageExt.Prelude; +using Seq = LanguageExt.Seq; namespace ErsatzTV.Core.Metadata { public class LocalMediaScanner : ILocalMediaScanner { + private readonly IImageCache _imageCache; private readonly ILocalFileSystem _localFileSystem; + private readonly ILocalMediaSourcePlanner _localMediaSourcePlanner; private readonly ILocalMetadataProvider _localMetadataProvider; - private readonly ILocalPosterProvider _localPosterProvider; private readonly ILocalStatisticsProvider _localStatisticsProvider; private readonly ILogger _logger; private readonly IMediaItemRepository _mediaItemRepository; @@ -29,21 +33,23 @@ namespace ErsatzTV.Core.Metadata IPlayoutRepository playoutRepository, ILocalStatisticsProvider localStatisticsProvider, ILocalMetadataProvider localMetadataProvider, - ILocalPosterProvider localPosterProvider, ISmartCollectionBuilder smartCollectionBuilder, IPlayoutBuilder playoutBuilder, - ILogger logger, - ILocalFileSystem localFileSystem) + ILocalMediaSourcePlanner localMediaSourcePlanner, + ILocalFileSystem localFileSystem, + IImageCache imageCache, + ILogger logger) { _mediaItemRepository = mediaItemRepository; _playoutRepository = playoutRepository; _localStatisticsProvider = localStatisticsProvider; _localMetadataProvider = localMetadataProvider; - _localPosterProvider = localPosterProvider; _smartCollectionBuilder = smartCollectionBuilder; _playoutBuilder = playoutBuilder; - _logger = logger; + _localMediaSourcePlanner = localMediaSourcePlanner; _localFileSystem = localFileSystem; + _imageCache = imageCache; + _logger = logger; } public async Task ScanLocalMediaSource( @@ -62,47 +68,77 @@ namespace ErsatzTV.Core.Metadata List knownMediaItems = await _mediaItemRepository.GetAllByMediaSourceId(localMediaSource.Id); var modifiedPlayoutIds = new List(); - Seq allFiles = _localFileSystem.FindRelevantVideos(localMediaSource); + Seq actions = _localMediaSourcePlanner.DetermineActions( + localMediaSource.MediaType, + knownMediaItems.ToSeq(), + FindAllFiles(localMediaSource)); - // check if the media item exists - (Seq newFiles, Seq existingMediaItems) = allFiles.Map( - s => Optional(knownMediaItems.Find(i => i.Path == s)).ToEither(s)) - .Partition(); - - // remove media items that no longer exist - var missingMediaItems = knownMediaItems.Filter(i => !allFiles.Contains(i.Path)).ToSeq(); - await RemoveMissingItems(missingMediaItems); - modifiedPlayoutIds.AddRange(await _playoutRepository.GetPlayoutIdsForMediaItems(missingMediaItems)); + foreach (LocalMediaSourcePlan action in actions) + { + Option maybeAddPlan = + action.ActionPlans.SingleOrDefault(plan => plan.TargetAction == ScanningAction.Add); + await maybeAddPlan.IfSomeAsync( + async plan => + { + Option maybeMediaItem = await AddMediaItem(localMediaSource, plan.TargetPath); - Seq staleMetadataMediaItems = scanningMode == ScanningMode.RescanAll - ? existingMediaItems - : existingMediaItems.Filter(i => _localFileSystem.ShouldRefreshMetadata(localMediaSource, i)); - Seq modifiedMediaItems = await RefreshMetadataForItems(ffprobePath, staleMetadataMediaItems); - modifiedPlayoutIds.AddRange(await _playoutRepository.GetPlayoutIdsForMediaItems(modifiedMediaItems)); + // any actions other than "add" need to operate on a media item + maybeMediaItem.IfSome(mediaItem => action.Source = mediaItem); + }); - // if new, add and store mtime, refresh metadata - var addedMediaItems = new List(); - foreach (string path in newFiles) - { - _logger.LogDebug("Adding new media item {MediaItem}", path); - var mediaItem = new MediaItem + foreach (ActionPlan plan in action.ActionPlans.OrderBy(plan => (int) plan.TargetAction)) { - MediaSourceId = localMediaSource.Id, - Path = path, - LastWriteTime = File.GetLastWriteTimeUtc(path) - }; + string sourcePath = action.Source.Match( + mediaItem => mediaItem.Path, + path => path); - await _mediaItemRepository.Add(mediaItem); - await RefreshMetadata(mediaItem, ffprobePath); - addedMediaItems.Add(mediaItem); - } + _logger.LogDebug( + "{Source}: {Action} with {File}", + Path.GetFileName(sourcePath), + plan.TargetAction, + Path.GetRelativePath(Path.GetDirectoryName(sourcePath) ?? string.Empty, plan.TargetPath)); - modifiedPlayoutIds.AddRange(await _playoutRepository.GetPlayoutIdsForMediaItems(addedMediaItems.ToSeq())); + await action.Source.Match( + async mediaItem => + { + var changed = false; - Seq stalePosterMediaItems = existingMediaItems - .Filter(_localFileSystem.ShouldRefreshPoster) - .Concat(addedMediaItems); - await RefreshPosterForItems(stalePosterMediaItems); + 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 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; + }); + } + } foreach (int playoutId in modifiedPlayoutIds.Distinct()) { @@ -119,65 +155,135 @@ namespace ErsatzTV.Core.Metadata return unit; } - private async Task> RefreshMetadataForItems( - string ffprobePath, - Seq staleMetadataMediaItems) + private Seq FindAllFiles(LocalMediaSource localMediaSource) { - var modifiedMediaItems = new List(); - foreach (MediaItem mediaItem in staleMetadataMediaItems) + Seq allDirectories = Directory + .GetDirectories(localMediaSource.Folder, "*", SearchOption.AllDirectories) + .ToSeq() + .Add(localMediaSource.Folder); + + // remove any directories with an .etvignore file locally, or in any parent directory + Seq excluded = allDirectories.Filter(path => File.Exists(Path.Combine(path, ".etvignore"))); + Seq relevantDirectories = allDirectories + .Filter(d => !excluded.Any(d.StartsWith)); + // .Filter(d => localMediaSource.MediaType == MediaType.Other || !IsExtrasFolder(d)); + + return relevantDirectories + .Collect(d => Directory.GetFiles(d, "*", SearchOption.TopDirectoryOnly)) + .OrderBy(identity) + .ToSeq(); + } + + private async Task> AddMediaItem(MediaSource mediaSource, string path) + { + try { - _logger.LogDebug("Refreshing metadata for media item {MediaItem}", mediaItem.Path); - if (await RefreshMetadata(mediaItem, ffprobePath)) + var mediaItem = new MediaItem { - // only queue playout rebuilds for media items - // where the duration or collections have changed - modifiedMediaItems.Add(mediaItem); - } - } + MediaSourceId = mediaSource.Id, + Path = path, + 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 stalePosterMediaItems) + private async Task RemoveMissingItem(MediaItem mediaItem) { - (Seq movies, Seq episodes) = stalePosterMediaItems - .Map(i => Optional(i).Filter(i2 => i2.Metadata?.MediaType == MediaType.TvShow).ToEither(i)) - .Partition(); + try + { + 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 - foreach (MediaItem movie in movies) + private async Task SavePosterForItem(MediaItem mediaItem, string posterPath) + { + try { - _logger.LogDebug("Refreshing poster for media item {MediaItem}", movie.Path); - await _localPosterProvider.RefreshPoster(movie); + byte[] originalBytes = await File.ReadAllBytesAsync(posterPath); + Either 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 - IEnumerable episodesToRefresh = episodes.GroupBy(e => e.Metadata.Title) - .SelectMany(g => (Option) g.FirstOrDefault()); + private async Task RefreshStatisticsForItem(MediaItem mediaItem, string ffprobePath) + { + 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 RefreshCollectionsForItem(MediaItem mediaItem) + { + try + { + return await _smartCollectionBuilder.RefreshSmartCollections(mediaItem); + } + catch (Exception ex) { - _logger.LogDebug("Refreshing poster for media item {MediaItem}", episode.Path); - await _localPosterProvider.RefreshPoster(episode); + _logger.LogError(ex, "Failed to refresh collections for media item {MediaItem}", mediaItem.Path); + return false; } } - private async Task RemoveMissingItems(Seq removedMediaItems) + private async Task RefreshSidecarMetadataForItem(MediaItem mediaItem, string path) { - // TODO: flag as missing? delete after some period of time? - foreach (MediaItem mediaItem in removedMediaItems) + try { - _logger.LogDebug("Removing missing local media item {MediaItem}", mediaItem.Path); - await _mediaItemRepository.Delete(mediaItem.Id); + await _localMetadataProvider.RefreshSidecarMetadata(mediaItem, path); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to refresh nfo metadata for media item {MediaItem}", mediaItem.Path); } } - private async Task RefreshMetadata(MediaItem mediaItem, string ffprobePath) + private async Task RefreshFallbackMetadataForItem(MediaItem mediaItem) { - bool durationChange = await _localStatisticsProvider.RefreshStatistics(ffprobePath, mediaItem); - await _localMetadataProvider.RefreshMetadata(mediaItem); - bool collectionChange = await _smartCollectionBuilder.RefreshSmartCollections(mediaItem); - return durationChange || collectionChange; + try + { + await _localMetadataProvider.RefreshFallbackMetadata(mediaItem); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to refresh fallback metadata for media item {MediaItem}", mediaItem.Path); + } } } } diff --git a/ErsatzTV.Core/Metadata/LocalMediaSourcePlan.cs b/ErsatzTV.Core/Metadata/LocalMediaSourcePlan.cs new file mode 100644 index 000000000..2ede96786 --- /dev/null +++ b/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 Source, List ActionPlans) + { + public Either Source { get; set; } = Source; + } +} diff --git a/ErsatzTV.Core/Metadata/LocalMediaSourcePlanner.cs b/ErsatzTV.Core/Metadata/LocalMediaSourcePlanner.cs new file mode 100644 index 000000000..91cfffd1f --- /dev/null +++ b/ErsatzTV.Core/Metadata/LocalMediaSourcePlanner.cs @@ -0,0 +1,179 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Interfaces.Metadata; +using LanguageExt; +using static LanguageExt.Prelude; + +namespace ErsatzTV.Core.Metadata +{ + // TODO: this needs a better name + public class LocalMediaSourcePlanner : ILocalMediaSourcePlanner + { + private static readonly Seq ImageFileExtensions = Seq("jpg", "jpeg", "png", "gif", "tbn"); + private readonly ILocalFileSystem _localFileSystem; + + public LocalMediaSourcePlanner(ILocalFileSystem localFileSystem) => _localFileSystem = localFileSystem; + + public Seq DetermineActions( + MediaType mediaType, + Seq mediaItems, + Seq files) + { + var results = new IntermediateResults(); + Seq videoFiles = files.Filter(f => VideoFileExtensions.Contains(Path.GetExtension(f))) + .Filter(f => !IsExtra(f)); + + (Seq newFiles, Seq existingMediaItems) = videoFiles.Map( + s => mediaItems.Find(i => i.Path == s).ToEither(s)) + .Partition(); + + // new files + foreach (string file in newFiles) + { + results.Add(file, new ActionPlan(file, ScanningAction.Add)); + results.Add(file, new ActionPlan(file, ScanningAction.Statistics)); + + Option maybeNfoFile = LocateNfoFile(mediaType, files, file); + maybeNfoFile.BiIter( + nfoFile => + { + results.Add(file, new ActionPlan(nfoFile, ScanningAction.SidecarMetadata)); + results.Add(file, new ActionPlan(nfoFile, ScanningAction.Collections)); + }, + () => + { + results.Add(file, new ActionPlan(file, ScanningAction.FallbackMetadata)); + results.Add(file, new ActionPlan(file, ScanningAction.Collections)); + }); + + Option maybePoster = LocatePoster(mediaType, files, file); + maybePoster.IfSome( + posterFile => results.Add(file, new ActionPlan(posterFile, ScanningAction.Poster))); + } + + // existing media items + foreach (MediaItem mediaItem in existingMediaItems) + { + if ((mediaItem.LastWriteTime ?? DateTime.MinValue) < _localFileSystem.GetLastWriteTime(mediaItem.Path)) + { + results.Add(mediaItem, new ActionPlan(mediaItem.Path, ScanningAction.Statistics)); + } + + Option maybeNfoFile = LocateNfoFile(mediaType, files, mediaItem.Path); + maybeNfoFile.IfSome( + nfoFile => + { + if (mediaItem.Metadata == null || mediaItem.Metadata.Source == MetadataSource.Fallback || + (mediaItem.Metadata.LastWriteTime ?? DateTime.MinValue) < + _localFileSystem.GetLastWriteTime(nfoFile)) + { + results.Add(mediaItem, new ActionPlan(nfoFile, ScanningAction.SidecarMetadata)); + results.Add(mediaItem, new ActionPlan(nfoFile, ScanningAction.Collections)); + } + }); + + Option maybePoster = LocatePoster(mediaType, files, mediaItem.Path); + maybePoster.IfSome( + posterFile => + { + if (string.IsNullOrWhiteSpace(mediaItem.Poster) || + (mediaItem.PosterLastWriteTime ?? DateTime.MinValue) < + _localFileSystem.GetLastWriteTime(posterFile)) + { + results.Add(mediaItem, new ActionPlan(posterFile, ScanningAction.Poster)); + } + }); + } + + // missing media items + foreach (MediaItem mediaItem in mediaItems.Where(i => !files.Contains(i.Path))) + { + results.Add(mediaItem, new ActionPlan(mediaItem.Path, ScanningAction.Remove)); + } + + return results.Summarize(); + } + + private static bool IsExtra(string path) + { + string folder = Path.GetFileName(Path.GetDirectoryName(path) ?? string.Empty); + string file = Path.GetFileNameWithoutExtension(path); + return ExtraDirectories.Contains(folder, StringComparer.OrdinalIgnoreCase) + || ExtraFiles.Any(f => file.EndsWith(f, StringComparison.OrdinalIgnoreCase)); + } + + private static Option LocateNfoFile(MediaType mediaType, Seq files, string file) + { + switch (mediaType) + { + case MediaType.Movie: + string movieAsNfo = Path.ChangeExtension(file, "nfo"); + string movieNfo = Path.Combine(Path.GetDirectoryName(file) ?? string.Empty, "movie.nfo"); + return Seq(movieAsNfo, movieNfo) + .Filter(s => files.Contains(s)) + .HeadOrNone(); + case MediaType.TvShow: + string episodeAsNfo = Path.ChangeExtension(file, "nfo"); + return Optional(episodeAsNfo) + .Filter(s => files.Contains(s)) + .HeadOrNone(); + } + + return None; + } + + private static Option LocatePoster(MediaType mediaType, Seq files, string file) + { + string folder = Path.GetDirectoryName(file) ?? string.Empty; + + switch (mediaType) + { + case MediaType.Movie: + IEnumerable possibleMoviePosters = ImageFileExtensions.Collect( + ext => new[] { $"poster.{ext}", Path.GetFileNameWithoutExtension(file) + $"-poster.{ext}" }) + .Map(f => Path.Combine(folder, f)); + return possibleMoviePosters.Filter(p => files.Contains(p)).HeadOrNone(); + case MediaType.TvShow: + string parentFolder = Directory.GetParent(folder)?.FullName ?? string.Empty; + IEnumerable possibleTvPosters = ImageFileExtensions + .Collect(ext => new[] { $"poster.{ext}" }) + .Map(f => Path.Combine(parentFolder, f)); + return possibleTvPosters.Filter(p => files.Contains(p)).HeadOrNone(); + } + + return None; + } + + private class IntermediateResults + { + private readonly List, ActionPlan>> _rawResults = new(); + + public void Add(Either source, ActionPlan plan) => + _rawResults.Add(Tuple(source, plan)); + + public Seq Summarize() => + _rawResults + .GroupBy(t => t.Item1) + .Select(g => new LocalMediaSourcePlan(g.Key, g.Select(g2 => g2.Item2).ToList())) + .ToSeq(); + } + + // @formatter:off + private static readonly Seq VideoFileExtensions = Seq( + ".mpg", ".mp2", ".mpeg", ".mpe", ".mpv", ".ogg", ".mp4", + ".m4p", ".m4v", ".avi", ".wmv", ".mov", ".mkv", ".ts"); + + private static readonly Seq ExtraDirectories = Seq( + "behind the scenes", "deleted scenes", "featurettes", + "interviews", "scenes", "shorts", "trailers", "other", + "extras", "specials"); + + private static readonly Seq ExtraFiles = Seq( + "behindthescenes", "deleted", "featurette", + "interview", "scene", "short", "trailer", "other"); + // @formatter:on + } +} diff --git a/ErsatzTV.Core/Metadata/LocalMetadataProvider.cs b/ErsatzTV.Core/Metadata/LocalMetadataProvider.cs index 9846c891f..c9d480757 100644 --- a/ErsatzTV.Core/Metadata/LocalMetadataProvider.cs +++ b/ErsatzTV.Core/Metadata/LocalMetadataProvider.cs @@ -25,14 +25,15 @@ namespace ErsatzTV.Core.Metadata _logger = logger; } - public async Task RefreshMetadata(MediaItem mediaItem) + public async Task RefreshSidecarMetadata(MediaItem mediaItem, string path) { - Option maybeMetadata = await LoadMetadata(mediaItem); - MediaMetadata metadata = - maybeMetadata.IfNone(() => FallbackMetadataProvider.GetFallbackMetadata(mediaItem)); - await ApplyMetadataUpdate(mediaItem, metadata); + Option maybeMetadata = await LoadMetadata(mediaItem, path); + await maybeMetadata.IfSomeAsync(metadata => ApplyMetadataUpdate(mediaItem, metadata)); } + public Task RefreshFallbackMetadata(MediaItem mediaItem) => + ApplyMetadataUpdate(mediaItem, FallbackMetadataProvider.GetFallbackMetadata(mediaItem)); + private async Task ApplyMetadataUpdate(MediaItem mediaItem, MediaMetadata metadata) { if (mediaItem.Metadata == null) @@ -40,6 +41,8 @@ namespace ErsatzTV.Core.Metadata mediaItem.Metadata = new MediaMetadata(); } + mediaItem.Metadata.Source = metadata.Source; + mediaItem.Metadata.LastWriteTime = metadata.LastWriteTime; mediaItem.Metadata.MediaType = metadata.MediaType; mediaItem.Metadata.Title = metadata.Title; mediaItem.Metadata.Subtitle = metadata.Subtitle; @@ -56,9 +59,8 @@ namespace ErsatzTV.Core.Metadata await _mediaItemRepository.Update(mediaItem); } - private async Task> LoadMetadata(MediaItem mediaItem) + private async Task> LoadMetadata(MediaItem mediaItem, string nfoFileName) { - string nfoFileName = Path.ChangeExtension(mediaItem.Path, "nfo"); if (nfoFileName == null || !File.Exists(nfoFileName)) { _logger.LogDebug("NFO file does not exist at {Path}", nfoFileName); @@ -88,6 +90,8 @@ namespace ErsatzTV.Core.Metadata return maybeNfo.Match>( nfo => new MediaMetadata { + Source = MetadataSource.Sidecar, + LastWriteTime = File.GetLastWriteTimeUtc(nfoFileName), MediaType = MediaType.TvShow, Title = nfo.ShowTitle, Subtitle = nfo.Title, @@ -114,6 +118,8 @@ namespace ErsatzTV.Core.Metadata return maybeNfo.Match>( nfo => new MediaMetadata { + Source = MetadataSource.Sidecar, + LastWriteTime = File.GetLastWriteTimeUtc(nfoFileName), MediaType = MediaType.Movie, Title = nfo.Title, Description = nfo.Outline, diff --git a/ErsatzTV.Core/Metadata/LocalPosterProvider.cs b/ErsatzTV.Core/Metadata/LocalPosterProvider.cs index 1e5012ad3..a90d79ae6 100644 --- a/ErsatzTV.Core/Metadata/LocalPosterProvider.cs +++ b/ErsatzTV.Core/Metadata/LocalPosterProvider.cs @@ -73,7 +73,7 @@ namespace ErsatzTV.Core.Metadata return None; } - private async Task SavePosterToDisk(MediaItem mediaItem, string posterPath) + public async Task SavePosterToDisk(MediaItem mediaItem, string posterPath) { byte[] originalBytes = await File.ReadAllBytesAsync(posterPath); Either maybeHash = await _imageCache.ResizeAndSaveImage(originalBytes, 220, null); diff --git a/ErsatzTV.Core/Metadata/ScanningAction.cs b/ErsatzTV.Core/Metadata/ScanningAction.cs new file mode 100644 index 000000000..e09b53def --- /dev/null +++ b/ErsatzTV.Core/Metadata/ScanningAction.cs @@ -0,0 +1,14 @@ +namespace ErsatzTV.Core.Metadata +{ + public enum ScanningAction + { + None = 0, + Add = 1, + Remove = 2, + Statistics = 3, + SidecarMetadata = 4, + FallbackMetadata = 5, + Collections = 6, + Poster = 7 + } +} diff --git a/ErsatzTV.Infrastructure/Migrations/20210215153541_MetadataOptimizations.Designer.cs b/ErsatzTV.Infrastructure/Migrations/20210215153541_MetadataOptimizations.Designer.cs new file mode 100644 index 000000000..6974ecfba --- /dev/null +++ b/ErsatzTV.Infrastructure/Migrations/20210215153541_MetadataOptimizations.Designer.cs @@ -0,0 +1,905 @@ +// +using System; +using ErsatzTV.Infrastructure.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +namespace ErsatzTV.Infrastructure.Migrations +{ + [DbContext(typeof(TvContext))] + [Migration("20210215153541_MetadataOptimizations")] + partial class MetadataOptimizations + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "5.0.3"); + + modelBuilder.Entity("ErsatzTV.Core.AggregateModels.GenericIntegerId", b => + { + b.Property("Id") + .HasColumnType("INTEGER"); + }); + + modelBuilder.Entity("ErsatzTV.Core.AggregateModels.MediaCollectionSummary", b => + { + b.Property("Id") + .HasColumnType("INTEGER"); + + b.Property("IsSimple") + .HasColumnType("INTEGER"); + + b.Property("ItemCount") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + }); + + modelBuilder.Entity("ErsatzTV.Core.AggregateModels.MediaItemSummary", b => + { + b.Property("MediaItemId") + .HasColumnType("INTEGER"); + + b.Property("Poster") + .HasColumnType("TEXT"); + + b.Property("SortTitle") + .HasColumnType("TEXT"); + + b.Property("Subtitle") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.Channel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("FFmpegProfileId") + .HasColumnType("INTEGER"); + + b.Property("Logo") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Number") + .HasColumnType("INTEGER"); + + b.Property("StreamingMode") + .HasColumnType("INTEGER"); + + b.Property("UniqueId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("FFmpegProfileId"); + + b.HasIndex("Number") + .IsUnique(); + + b.ToTable("Channels"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.ConfigElement", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Key") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("ConfigElements"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.FFmpegProfile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AudioBitrate") + .HasColumnType("INTEGER"); + + b.Property("AudioBufferSize") + .HasColumnType("INTEGER"); + + b.Property("AudioChannels") + .HasColumnType("INTEGER"); + + b.Property("AudioCodec") + .HasColumnType("TEXT"); + + b.Property("AudioSampleRate") + .HasColumnType("INTEGER"); + + b.Property("AudioVolume") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizeAudio") + .HasColumnType("INTEGER"); + + b.Property("NormalizeAudioCodec") + .HasColumnType("INTEGER"); + + b.Property("NormalizeResolution") + .HasColumnType("INTEGER"); + + b.Property("NormalizeVideoCodec") + .HasColumnType("INTEGER"); + + b.Property("ResolutionId") + .HasColumnType("INTEGER"); + + b.Property("ThreadCount") + .HasColumnType("INTEGER"); + + b.Property("Transcode") + .HasColumnType("INTEGER"); + + b.Property("VideoBitrate") + .HasColumnType("INTEGER"); + + b.Property("VideoBufferSize") + .HasColumnType("INTEGER"); + + b.Property("VideoCodec") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ResolutionId"); + + b.ToTable("FFmpegProfiles"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.MediaCollection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("MediaCollections"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.MediaItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LastWriteTime") + .HasColumnType("TEXT"); + + b.Property("MediaSourceId") + .HasColumnType("INTEGER"); + + b.Property("Path") + .HasColumnType("TEXT"); + + b.Property("Poster") + .HasColumnType("TEXT"); + + b.Property("PosterLastWriteTime") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("MediaSourceId"); + + b.ToTable("MediaItems"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.MediaSource", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("SourceType") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("MediaSources"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.Playout", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChannelId") + .HasColumnType("INTEGER"); + + b.Property("ProgramScheduleId") + .HasColumnType("INTEGER"); + + b.Property("ProgramSchedulePlayoutType") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChannelId"); + + b.HasIndex("ProgramScheduleId"); + + b.ToTable("Playouts"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.PlayoutItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Finish") + .HasColumnType("TEXT"); + + b.Property("MediaItemId") + .HasColumnType("INTEGER"); + + b.Property("PlayoutId") + .HasColumnType("INTEGER"); + + b.Property("Start") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("MediaItemId"); + + b.HasIndex("PlayoutId"); + + b.ToTable("PlayoutItems"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.PlayoutProgramScheduleAnchor", b => + { + b.Property("PlayoutId") + .HasColumnType("INTEGER"); + + b.Property("ProgramScheduleId") + .HasColumnType("INTEGER"); + + b.Property("MediaCollectionId") + .HasColumnType("INTEGER"); + + b.HasKey("PlayoutId", "ProgramScheduleId", "MediaCollectionId"); + + b.HasIndex("MediaCollectionId"); + + b.HasIndex("ProgramScheduleId"); + + b.ToTable("PlayoutProgramScheduleItemAnchors"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.PlexMediaSourceConnection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("PlexMediaSourceId") + .HasColumnType("INTEGER"); + + b.Property("Uri") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PlexMediaSourceId"); + + b.ToTable("PlexMediaSourceConnections"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.PlexMediaSourceLibrary", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Key") + .HasColumnType("TEXT"); + + b.Property("MediaType") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("PlexMediaSourceId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PlexMediaSourceId"); + + b.ToTable("PlexMediaSourceLibraries"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.ProgramSchedule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("MediaCollectionPlaybackOrder") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("ProgramSchedules"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.ProgramScheduleItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Index") + .HasColumnType("INTEGER"); + + b.Property("MediaCollectionId") + .HasColumnType("INTEGER"); + + b.Property("ProgramScheduleId") + .HasColumnType("INTEGER"); + + b.Property("StartTime") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("MediaCollectionId"); + + b.HasIndex("ProgramScheduleId"); + + b.ToTable("ProgramScheduleItems"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.Resolution", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Height") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Width") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Resolutions"); + }); + + modelBuilder.Entity("MediaItemSimpleMediaCollection", b => + { + b.Property("ItemsId") + .HasColumnType("INTEGER"); + + b.Property("SimpleMediaCollectionsId") + .HasColumnType("INTEGER"); + + b.HasKey("ItemsId", "SimpleMediaCollectionsId"); + + b.HasIndex("SimpleMediaCollectionsId"); + + b.ToTable("MediaItemSimpleMediaCollection"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.SimpleMediaCollection", b => + { + b.HasBaseType("ErsatzTV.Core.Domain.MediaCollection"); + + b.ToTable("SimpleMediaCollections"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.TelevisionMediaCollection", b => + { + b.HasBaseType("ErsatzTV.Core.Domain.MediaCollection"); + + b.Property("SeasonNumber") + .HasColumnType("INTEGER"); + + b.Property("ShowTitle") + .HasColumnType("TEXT"); + + b.HasIndex("ShowTitle", "SeasonNumber") + .IsUnique(); + + b.ToTable("TelevisionMediaCollections"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.LocalMediaSource", b => + { + b.HasBaseType("ErsatzTV.Core.Domain.MediaSource"); + + b.Property("Folder") + .HasColumnType("TEXT"); + + b.Property("MediaType") + .HasColumnType("INTEGER"); + + b.ToTable("LocalMediaSources"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.PlexMediaSource", b => + { + b.HasBaseType("ErsatzTV.Core.Domain.MediaSource"); + + b.Property("ClientIdentifier") + .HasColumnType("TEXT"); + + b.Property("ProductVersion") + .HasColumnType("TEXT"); + + b.ToTable("PlexMediaSources"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.ProgramScheduleItemDuration", b => + { + b.HasBaseType("ErsatzTV.Core.Domain.ProgramScheduleItem"); + + b.Property("OfflineTail") + .HasColumnType("INTEGER"); + + b.Property("PlayoutDuration") + .HasColumnType("TEXT"); + + b.ToTable("ProgramScheduleDurationItems"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.ProgramScheduleItemFlood", b => + { + b.HasBaseType("ErsatzTV.Core.Domain.ProgramScheduleItem"); + + b.ToTable("ProgramScheduleFloodItems"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.ProgramScheduleItemMultiple", b => + { + b.HasBaseType("ErsatzTV.Core.Domain.ProgramScheduleItem"); + + b.Property("Count") + .HasColumnType("INTEGER"); + + b.ToTable("ProgramScheduleMultipleItems"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.ProgramScheduleItemOne", b => + { + b.HasBaseType("ErsatzTV.Core.Domain.ProgramScheduleItem"); + + b.ToTable("ProgramScheduleOneItems"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.Channel", b => + { + b.HasOne("ErsatzTV.Core.Domain.FFmpegProfile", "FFmpegProfile") + .WithMany() + .HasForeignKey("FFmpegProfileId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("FFmpegProfile"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.FFmpegProfile", b => + { + b.HasOne("ErsatzTV.Core.Domain.Resolution", "Resolution") + .WithMany() + .HasForeignKey("ResolutionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Resolution"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.MediaItem", b => + { + b.HasOne("ErsatzTV.Core.Domain.MediaSource", "Source") + .WithMany() + .HasForeignKey("MediaSourceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.OwnsOne("ErsatzTV.Core.Domain.MediaMetadata", "Metadata", b1 => + { + b1.Property("MediaItemId") + .HasColumnType("INTEGER"); + + b1.Property("Aired") + .HasColumnType("TEXT"); + + b1.Property("AudioCodec") + .HasColumnType("TEXT"); + + b1.Property("ContentRating") + .HasColumnType("TEXT"); + + b1.Property("Description") + .HasColumnType("TEXT"); + + b1.Property("DisplayAspectRatio") + .HasColumnType("TEXT"); + + b1.Property("Duration") + .HasColumnType("TEXT"); + + b1.Property("EpisodeNumber") + .HasColumnType("INTEGER"); + + b1.Property("Height") + .HasColumnType("INTEGER"); + + b1.Property("LastWriteTime") + .HasColumnType("TEXT"); + + b1.Property("MediaType") + .HasColumnType("INTEGER"); + + b1.Property("SampleAspectRatio") + .HasColumnType("TEXT"); + + b1.Property("SeasonNumber") + .HasColumnType("INTEGER"); + + b1.Property("SortTitle") + .HasColumnType("TEXT"); + + b1.Property("Source") + .HasColumnType("INTEGER"); + + b1.Property("Subtitle") + .HasColumnType("TEXT"); + + b1.Property("Title") + .HasColumnType("TEXT"); + + b1.Property("VideoCodec") + .HasColumnType("TEXT"); + + b1.Property("VideoScanType") + .HasColumnType("INTEGER"); + + b1.Property("Width") + .HasColumnType("INTEGER"); + + b1.HasKey("MediaItemId"); + + b1.ToTable("MediaItems"); + + b1.WithOwner() + .HasForeignKey("MediaItemId"); + }); + + b.Navigation("Metadata"); + + b.Navigation("Source"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.Playout", b => + { + b.HasOne("ErsatzTV.Core.Domain.Channel", "Channel") + .WithMany("Playouts") + .HasForeignKey("ChannelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ErsatzTV.Core.Domain.ProgramSchedule", "ProgramSchedule") + .WithMany("Playouts") + .HasForeignKey("ProgramScheduleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.OwnsOne("ErsatzTV.Core.Domain.PlayoutAnchor", "Anchor", b1 => + { + b1.Property("PlayoutId") + .HasColumnType("INTEGER"); + + b1.Property("NextScheduleItemId") + .HasColumnType("INTEGER"); + + b1.Property("NextStart") + .HasColumnType("TEXT"); + + b1.HasKey("PlayoutId"); + + b1.HasIndex("NextScheduleItemId"); + + b1.ToTable("Playouts"); + + b1.HasOne("ErsatzTV.Core.Domain.ProgramScheduleItem", "NextScheduleItem") + .WithMany() + .HasForeignKey("NextScheduleItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b1.WithOwner() + .HasForeignKey("PlayoutId"); + + b1.Navigation("NextScheduleItem"); + }); + + b.Navigation("Anchor"); + + b.Navigation("Channel"); + + b.Navigation("ProgramSchedule"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.PlayoutItem", b => + { + b.HasOne("ErsatzTV.Core.Domain.MediaItem", "MediaItem") + .WithMany() + .HasForeignKey("MediaItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ErsatzTV.Core.Domain.Playout", "Playout") + .WithMany("Items") + .HasForeignKey("PlayoutId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("MediaItem"); + + b.Navigation("Playout"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.PlayoutProgramScheduleAnchor", b => + { + b.HasOne("ErsatzTV.Core.Domain.MediaCollection", "MediaCollection") + .WithMany() + .HasForeignKey("MediaCollectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ErsatzTV.Core.Domain.Playout", "Playout") + .WithMany("ProgramScheduleAnchors") + .HasForeignKey("PlayoutId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ErsatzTV.Core.Domain.ProgramSchedule", "ProgramSchedule") + .WithMany() + .HasForeignKey("ProgramScheduleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.OwnsOne("ErsatzTV.Core.Domain.MediaCollectionEnumeratorState", "EnumeratorState", b1 => + { + b1.Property("PlayoutProgramScheduleAnchorPlayoutId") + .HasColumnType("INTEGER"); + + b1.Property("PlayoutProgramScheduleAnchorProgramScheduleId") + .HasColumnType("INTEGER"); + + b1.Property("PlayoutProgramScheduleAnchorMediaCollectionId") + .HasColumnType("INTEGER"); + + b1.Property("Index") + .HasColumnType("INTEGER"); + + b1.Property("Seed") + .HasColumnType("INTEGER"); + + b1.HasKey("PlayoutProgramScheduleAnchorPlayoutId", "PlayoutProgramScheduleAnchorProgramScheduleId", "PlayoutProgramScheduleAnchorMediaCollectionId"); + + b1.ToTable("PlayoutProgramScheduleItemAnchors"); + + b1.WithOwner() + .HasForeignKey("PlayoutProgramScheduleAnchorPlayoutId", "PlayoutProgramScheduleAnchorProgramScheduleId", "PlayoutProgramScheduleAnchorMediaCollectionId"); + }); + + b.Navigation("EnumeratorState"); + + b.Navigation("MediaCollection"); + + b.Navigation("Playout"); + + b.Navigation("ProgramSchedule"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.PlexMediaSourceConnection", b => + { + b.HasOne("ErsatzTV.Core.Domain.PlexMediaSource", null) + .WithMany("Connections") + .HasForeignKey("PlexMediaSourceId"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.PlexMediaSourceLibrary", b => + { + b.HasOne("ErsatzTV.Core.Domain.PlexMediaSource", null) + .WithMany("Libraries") + .HasForeignKey("PlexMediaSourceId"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.ProgramScheduleItem", b => + { + b.HasOne("ErsatzTV.Core.Domain.MediaCollection", "MediaCollection") + .WithMany() + .HasForeignKey("MediaCollectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ErsatzTV.Core.Domain.ProgramSchedule", "ProgramSchedule") + .WithMany("Items") + .HasForeignKey("ProgramScheduleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("MediaCollection"); + + b.Navigation("ProgramSchedule"); + }); + + modelBuilder.Entity("MediaItemSimpleMediaCollection", b => + { + b.HasOne("ErsatzTV.Core.Domain.MediaItem", null) + .WithMany() + .HasForeignKey("ItemsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ErsatzTV.Core.Domain.SimpleMediaCollection", null) + .WithMany() + .HasForeignKey("SimpleMediaCollectionsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.SimpleMediaCollection", b => + { + b.HasOne("ErsatzTV.Core.Domain.MediaCollection", null) + .WithOne() + .HasForeignKey("ErsatzTV.Core.Domain.SimpleMediaCollection", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.TelevisionMediaCollection", b => + { + b.HasOne("ErsatzTV.Core.Domain.MediaCollection", null) + .WithOne() + .HasForeignKey("ErsatzTV.Core.Domain.TelevisionMediaCollection", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.LocalMediaSource", b => + { + b.HasOne("ErsatzTV.Core.Domain.MediaSource", null) + .WithOne() + .HasForeignKey("ErsatzTV.Core.Domain.LocalMediaSource", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.PlexMediaSource", b => + { + b.HasOne("ErsatzTV.Core.Domain.MediaSource", null) + .WithOne() + .HasForeignKey("ErsatzTV.Core.Domain.PlexMediaSource", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.ProgramScheduleItemDuration", b => + { + b.HasOne("ErsatzTV.Core.Domain.ProgramScheduleItem", null) + .WithOne() + .HasForeignKey("ErsatzTV.Core.Domain.ProgramScheduleItemDuration", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.ProgramScheduleItemFlood", b => + { + b.HasOne("ErsatzTV.Core.Domain.ProgramScheduleItem", null) + .WithOne() + .HasForeignKey("ErsatzTV.Core.Domain.ProgramScheduleItemFlood", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.ProgramScheduleItemMultiple", b => + { + b.HasOne("ErsatzTV.Core.Domain.ProgramScheduleItem", null) + .WithOne() + .HasForeignKey("ErsatzTV.Core.Domain.ProgramScheduleItemMultiple", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.ProgramScheduleItemOne", b => + { + b.HasOne("ErsatzTV.Core.Domain.ProgramScheduleItem", null) + .WithOne() + .HasForeignKey("ErsatzTV.Core.Domain.ProgramScheduleItemOne", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.Channel", b => + { + b.Navigation("Playouts"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.Playout", b => + { + b.Navigation("Items"); + + b.Navigation("ProgramScheduleAnchors"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.ProgramSchedule", b => + { + b.Navigation("Items"); + + b.Navigation("Playouts"); + }); + + modelBuilder.Entity("ErsatzTV.Core.Domain.PlexMediaSource", b => + { + b.Navigation("Connections"); + + b.Navigation("Libraries"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/ErsatzTV.Infrastructure/Migrations/20210215153541_MetadataOptimizations.cs b/ErsatzTV.Infrastructure/Migrations/20210215153541_MetadataOptimizations.cs new file mode 100644 index 000000000..33ff9af95 --- /dev/null +++ b/ErsatzTV.Infrastructure/Migrations/20210215153541_MetadataOptimizations.cs @@ -0,0 +1,44 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +namespace ErsatzTV.Infrastructure.Migrations +{ + public partial class MetadataOptimizations : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + "Metadata_LastWriteTime", + "MediaItems", + "TEXT", + nullable: true); + + migrationBuilder.AddColumn( + "Metadata_Source", + "MediaItems", + "INTEGER", + nullable: true); + + migrationBuilder.AddColumn( + "PosterLastWriteTime", + "MediaItems", + "TEXT", + nullable: true); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + "Metadata_LastWriteTime", + "MediaItems"); + + migrationBuilder.DropColumn( + "Metadata_Source", + "MediaItems"); + + migrationBuilder.DropColumn( + "PosterLastWriteTime", + "MediaItems"); + } + } +} diff --git a/ErsatzTV.Infrastructure/Migrations/TvContextModelSnapshot.cs b/ErsatzTV.Infrastructure/Migrations/TvContextModelSnapshot.cs index 80651b368..7f072691e 100644 --- a/ErsatzTV.Infrastructure/Migrations/TvContextModelSnapshot.cs +++ b/ErsatzTV.Infrastructure/Migrations/TvContextModelSnapshot.cs @@ -45,6 +45,9 @@ namespace ErsatzTV.Infrastructure.Migrations "ErsatzTV.Core.AggregateModels.MediaItemSummary", b => { + b.Property("MediaItemId") + .HasColumnType("INTEGER"); + b.Property("Poster") .HasColumnType("TEXT"); @@ -221,6 +224,9 @@ namespace ErsatzTV.Infrastructure.Migrations b.Property("Poster") .HasColumnType("TEXT"); + b.Property("PosterLastWriteTime") + .HasColumnType("TEXT"); + b.HasKey("Id"); b.HasIndex("MediaSourceId"); @@ -638,6 +644,9 @@ namespace ErsatzTV.Infrastructure.Migrations b1.Property("Height") .HasColumnType("INTEGER"); + b1.Property("LastWriteTime") + .HasColumnType("TEXT"); + b1.Property("MediaType") .HasColumnType("INTEGER"); @@ -650,6 +659,9 @@ namespace ErsatzTV.Infrastructure.Migrations b1.Property("SortTitle") .HasColumnType("TEXT"); + b1.Property("Source") + .HasColumnType("INTEGER"); + b1.Property("Subtitle") .HasColumnType("TEXT"); diff --git a/ErsatzTV/Services/WorkerService.cs b/ErsatzTV/Services/WorkerService.cs index a7f1bbb19..75538af40 100644 --- a/ErsatzTV/Services/WorkerService.cs +++ b/ErsatzTV/Services/WorkerService.cs @@ -59,7 +59,7 @@ namespace ErsatzTV.Services case RefreshMediaItem refreshMediaItem: string type = refreshMediaItem switch { - RefreshMediaItemMetadata => "metadata", + // RefreshMediaItemMetadata => "metadata", RefreshMediaItemStatistics => "statistics", RefreshMediaItemCollections => "collections", RefreshMediaItemPoster => "poster", diff --git a/ErsatzTV/Shared/LocalMediaSources.razor b/ErsatzTV/Shared/LocalMediaSources.razor index 07c60f4bb..163e4d28e 100644 --- a/ErsatzTV/Shared/LocalMediaSources.razor +++ b/ErsatzTV/Shared/LocalMediaSources.razor @@ -32,7 +32,7 @@ } else { - + diff --git a/ErsatzTV/Startup.cs b/ErsatzTV/Startup.cs index f3ffd9c96..c50781ff0 100644 --- a/ErsatzTV/Startup.cs +++ b/ErsatzTV/Startup.cs @@ -180,6 +180,7 @@ namespace ErsatzTV services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddHostedService(); services.AddHostedService();