diff --git a/ErsatzTV.Core.Tests/Metadata/TestMediaScannerTests.cs b/ErsatzTV.Core.Tests/Metadata/TestMediaScannerTests.cs new file mode 100644 index 000000000..9fa8aec08 --- /dev/null +++ b/ErsatzTV.Core.Tests/Metadata/TestMediaScannerTests.cs @@ -0,0 +1,119 @@ +using System.Collections.Generic; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Metadata; +using FluentAssertions; +using LanguageExt; +using NUnit.Framework; + +namespace ErsatzTV.Core.Tests.Metadata +{ + [TestFixture] + public class TestMediaScannerTests + { + private readonly TestMediaScanner _scanner = new(); + + [Test] + public void NewMovieFile_WithoutNfo_WithoutPoster() + { + // new movie file without nfo and without poster should have statistics and fallback metadata + var movieFileName = "/movies/test (2021)/test (2021).mkv"; + string[] fileNames = { movieFileName }; + + Seq result = _scanner.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 ItemScanningPlan(movieFileName, ScanningAction.Statistics), + new ItemScanningPlan(movieFileName, ScanningAction.FallbackMetadata)); + } + + [Test] + public void NewMovieFile_WithNfo_WithoutPoster( + [Values("test (2021).nfo", "movie.nfo")] + string nfoFile) + { + // new movie file without nfo and without poster should have statistics and fallback metadata + var movieFileName = "/movies/test (2021)/test (2021).mkv"; + var nfoFileName = $"/movies/test (2021)/{nfoFile}"; + string[] fileNames = { movieFileName, nfoFileName }; + + Seq result = _scanner.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 ItemScanningPlan(movieFileName, ScanningAction.Statistics), + new ItemScanningPlan(nfoFileName, ScanningAction.SidecarMetadata)); + } + + [Test] + public void NewMovieFile_WithoutNfo_WithPoster( + [Values("", "test (2021)-")] + string basePosterName, + [Values("jpg", "jpeg", "png", "gif", "tbn")] + string posterExtension) + { + // new movie file without nfo and without poster should have statistics and fallback metadata + var movieFileName = "/movies/test (2021)/test (2021).mkv"; + var posterFileName = $"/movies/test (2021)/{basePosterName}poster.{posterExtension}"; + + string[] fileNames = { movieFileName, posterFileName }; + + Seq result = _scanner.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 ItemScanningPlan(movieFileName, ScanningAction.Statistics), + new ItemScanningPlan(movieFileName, ScanningAction.FallbackMetadata), + new ItemScanningPlan(posterFileName, ScanningAction.Poster)); + } + + [Test] + public void NewMovieFile_WithNfo_WithPoster( + [Values("test (2021).nfo", "movie.nfo")] + string nfoFile, + [Values("", "test (2021)-")] + string basePosterName, + [Values("jpg", "jpeg", "png", "gif", "tbn")] + string posterExtension) + { + // new movie file without nfo and without poster should have statistics and fallback metadata + var movieFileName = "/movies/test (2021)/test (2021).mkv"; + var nfoFileName = $"/movies/test (2021)/{nfoFile}"; + var posterFileName = $"/movies/test (2021)/{basePosterName}poster.{posterExtension}"; + + string[] fileNames = { movieFileName, nfoFileName, posterFileName }; + + Seq result = _scanner.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 ItemScanningPlan(movieFileName, ScanningAction.Statistics), + new ItemScanningPlan(nfoFileName, ScanningAction.SidecarMetadata), + new ItemScanningPlan(posterFileName, ScanningAction.Poster)); + } + } +} diff --git a/ErsatzTV.Core/Domain/MediaMetadata.cs b/ErsatzTV.Core/Domain/MediaMetadata.cs index efe2774bd..71421f165 100644 --- a/ErsatzTV.Core/Domain/MediaMetadata.cs +++ b/ErsatzTV.Core/Domain/MediaMetadata.cs @@ -5,6 +5,7 @@ namespace ErsatzTV.Core.Domain { public record MediaMetadata : IDisplaySize { + public MetadataSource Source { 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..1c3ecfc0e --- /dev/null +++ b/ErsatzTV.Core/Domain/MetadataSource.cs @@ -0,0 +1,8 @@ +namespace ErsatzTV.Core.Domain +{ + public enum MetadataSource + { + Sidecar = 1, + Fallback = 2 + } +} 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/ItemScanningPlan.cs b/ErsatzTV.Core/Metadata/ItemScanningPlan.cs new file mode 100644 index 000000000..193b5fe87 --- /dev/null +++ b/ErsatzTV.Core/Metadata/ItemScanningPlan.cs @@ -0,0 +1,4 @@ +namespace ErsatzTV.Core.Metadata +{ + public record ItemScanningPlan(string TargetPath, ScanningAction TargetAction); +} diff --git a/ErsatzTV.Core/Metadata/LocalMediaItemScanningPlan.cs b/ErsatzTV.Core/Metadata/LocalMediaItemScanningPlan.cs new file mode 100644 index 000000000..2e8d0656d --- /dev/null +++ b/ErsatzTV.Core/Metadata/LocalMediaItemScanningPlan.cs @@ -0,0 +1,8 @@ +using System.Collections.Generic; +using ErsatzTV.Core.Domain; +using LanguageExt; + +namespace ErsatzTV.Core.Metadata +{ + public record LocalMediaItemScanningPlan(Either Source, List ActionPlans); +} diff --git a/ErsatzTV.Core/Metadata/LocalMetadataProvider.cs b/ErsatzTV.Core/Metadata/LocalMetadataProvider.cs index 9846c891f..fe96042b7 100644 --- a/ErsatzTV.Core/Metadata/LocalMetadataProvider.cs +++ b/ErsatzTV.Core/Metadata/LocalMetadataProvider.cs @@ -40,6 +40,7 @@ namespace ErsatzTV.Core.Metadata mediaItem.Metadata = new MediaMetadata(); } + mediaItem.Metadata.Source = metadata.Source; mediaItem.Metadata.MediaType = metadata.MediaType; mediaItem.Metadata.Title = metadata.Title; mediaItem.Metadata.Subtitle = metadata.Subtitle; @@ -88,6 +89,7 @@ namespace ErsatzTV.Core.Metadata return maybeNfo.Match>( nfo => new MediaMetadata { + Source = MetadataSource.Sidecar, MediaType = MediaType.TvShow, Title = nfo.ShowTitle, Subtitle = nfo.Title, @@ -114,6 +116,7 @@ namespace ErsatzTV.Core.Metadata return maybeNfo.Match>( nfo => new MediaMetadata { + Source = MetadataSource.Sidecar, MediaType = MediaType.Movie, Title = nfo.Title, Description = nfo.Outline, diff --git a/ErsatzTV.Core/Metadata/ScanningAction.cs b/ErsatzTV.Core/Metadata/ScanningAction.cs new file mode 100644 index 000000000..cbb297a06 --- /dev/null +++ b/ErsatzTV.Core/Metadata/ScanningAction.cs @@ -0,0 +1,12 @@ +namespace ErsatzTV.Core.Metadata +{ + public enum ScanningAction + { + None = 0, + Statistics = 1, + SidecarMetadata = 2, + FallbackMetadata = 3, + Collections = 4, + Poster = 5 + } +} diff --git a/ErsatzTV.Core/Metadata/TestMediaScanner.cs b/ErsatzTV.Core/Metadata/TestMediaScanner.cs new file mode 100644 index 000000000..074b20a14 --- /dev/null +++ b/ErsatzTV.Core/Metadata/TestMediaScanner.cs @@ -0,0 +1,98 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using ErsatzTV.Core.Domain; +using LanguageExt; +using static LanguageExt.Prelude; + +namespace ErsatzTV.Core.Metadata +{ + public class TestMediaScanner + { + // @formatter:off + private static readonly Seq VideoFileExtensions = Seq( + ".mpg", ".mp2", ".mpeg", ".mpe", ".mpv", ".ogg", ".mp4", + ".m4p", ".m4v", ".avi", ".wmv", ".mov", ".mkv", ".ts"); + // @formatter:on + + private static readonly Seq ImageFileExtensions = Seq("jpg", "jpeg", "png", "gif", "tbn"); + + public Seq DetermineActions( + MediaType mediaType, + Seq mediaItems, + Seq files) + { + var results = new IntermediateResults(); + var videoFiles = files.Filter(f => VideoFileExtensions.Contains(Path.GetExtension(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 ItemScanningPlan(file, ScanningAction.Statistics)); + + Option maybeNfoFile = LocateNfoFile(mediaType, files, file); + maybeNfoFile.BiIter( + nfoFile => results.Add(file, new ItemScanningPlan(nfoFile, ScanningAction.SidecarMetadata)), + () => results.Add(file, new ItemScanningPlan(file, ScanningAction.FallbackMetadata))); + + Option maybePoster = LocatePoster(mediaType, files, file); + maybePoster.IfSome( + posterFile => results.Add(file, new ItemScanningPlan(posterFile, ScanningAction.Poster))); + } + + // existing media items + + return results.Summarize(); + } + + 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(); + } + + 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 possiblePosters = ImageFileExtensions.Collect( + ext => new[] { $"poster.{ext}", Path.GetFileNameWithoutExtension(file) + $"-poster.{ext}" }) + .Map(f => Path.Combine(folder, f)); + return possiblePosters.Filter(s => files.Contains(s)).HeadOrNone(); + } + + return None; + } + + private class IntermediateResults + { + private readonly List, ItemScanningPlan>> _rawResults = new(); + + public void Add(Either source, ItemScanningPlan plan) => + _rawResults.Add(Tuple(source, plan)); + + public Seq Summarize() => + _rawResults + .GroupBy(t => t.Item1) + .Select(g => new LocalMediaItemScanningPlan(g.Key, g.Select(g2 => g2.Item2).ToList())) + .ToSeq(); + } + } +}