diff --git a/ErsatzTV.Application/MediaItems/Mapper.cs b/ErsatzTV.Application/MediaItems/Mapper.cs index e72cd160e..6680e7dc2 100644 --- a/ErsatzTV.Application/MediaItems/Mapper.cs +++ b/ErsatzTV.Application/MediaItems/Mapper.cs @@ -14,7 +14,7 @@ namespace ErsatzTV.Application.MediaItems internal static MediaItemSearchResultViewModel ProjectToSearchViewModel(MediaItem mediaItem) => new( mediaItem.Id, - mediaItem.Source.Name, + GetSourceName(mediaItem.Source), mediaItem.Metadata.MediaType.ToString(), GetDisplayTitle(mediaItem), GetDisplayDuration(mediaItem)); @@ -31,5 +31,12 @@ namespace ErsatzTV.Application.MediaItems string.Format( mediaItem.Metadata.Duration.TotalHours >= 1 ? @"{0:h\:mm\:ss}" : @"{0:mm\:ss}", mediaItem.Metadata.Duration); + + private static string GetSourceName(MediaSource source) => + source switch + { + LocalMediaSource lms => lms.Folder, + _ => source.Name + }; } } diff --git a/ErsatzTV.Application/MediaSources/Commands/ScanLocalMediaSource.cs b/ErsatzTV.Application/MediaSources/Commands/ScanLocalMediaSource.cs index 0e08d61da..818949a2d 100644 --- a/ErsatzTV.Application/MediaSources/Commands/ScanLocalMediaSource.cs +++ b/ErsatzTV.Application/MediaSources/Commands/ScanLocalMediaSource.cs @@ -1,9 +1,11 @@ using ErsatzTV.Core; +using ErsatzTV.Core.Metadata; using LanguageExt; using MediatR; namespace ErsatzTV.Application.MediaSources.Commands { - public record ScanLocalMediaSource(int MediaSourceId) : IRequest>, + public record ScanLocalMediaSource(int MediaSourceId, ScanningMode ScanningMode) : + IRequest>, IBackgroundServiceRequest; } diff --git a/ErsatzTV.Application/MediaSources/Commands/ScanLocalMediaSourceHandler.cs b/ErsatzTV.Application/MediaSources/Commands/ScanLocalMediaSourceHandler.cs index 968f456cf..655f5df9a 100644 --- a/ErsatzTV.Application/MediaSources/Commands/ScanLocalMediaSourceHandler.cs +++ b/ErsatzTV.Application/MediaSources/Commands/ScanLocalMediaSourceHandler.cs @@ -3,37 +3,52 @@ using System.Threading; using System.Threading.Tasks; using ErsatzTV.Core; using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Interfaces.Locking; using ErsatzTV.Core.Interfaces.Metadata; using ErsatzTV.Core.Interfaces.Repositories; using LanguageExt; using MediatR; +using Unit = LanguageExt.Unit; namespace ErsatzTV.Application.MediaSources.Commands { public class ScanLocalMediaSourceHandler : IRequestHandler> { private readonly IConfigElementRepository _configElementRepository; + private readonly IEntityLocker _entityLocker; private readonly ILocalMediaScanner _localMediaScanner; private readonly IMediaSourceRepository _mediaSourceRepository; public ScanLocalMediaSourceHandler( IMediaSourceRepository mediaSourceRepository, IConfigElementRepository configElementRepository, - ILocalMediaScanner localMediaScanner) + ILocalMediaScanner localMediaScanner, + IEntityLocker entityLocker) { _mediaSourceRepository = mediaSourceRepository; _configElementRepository = configElementRepository; _localMediaScanner = localMediaScanner; + _entityLocker = entityLocker; } public Task> Handle(ScanLocalMediaSource request, CancellationToken cancellationToken) => Validate(request) - .MapT( - p => _localMediaScanner.ScanLocalMediaSource(p.LocalMediaSource, p.FFprobePath) - .Map(_ => p.LocalMediaSource.Folder)) + .MapT(parameters => PerformScan(request, parameters).Map(_ => parameters.LocalMediaSource.Folder)) .Bind(v => v.ToEitherAsync()); + private async Task PerformScan(ScanLocalMediaSource request, RequestParameters parameters) + { + await _localMediaScanner.ScanLocalMediaSource( + parameters.LocalMediaSource, + parameters.FFprobePath, + request.ScanningMode); + + _entityLocker.UnlockMediaSource(parameters.LocalMediaSource.Id); + + return Unit.Default; + } + private async Task> Validate(ScanLocalMediaSource request) => (await LocalMediaSourceMustExist(request), await ValidateFFprobePath()) .Apply((localMediaSource, ffprobePath) => new RequestParameters(localMediaSource, ffprobePath)); diff --git a/ErsatzTV.Application/Playouts/Commands/CreatePlayoutHandler.cs b/ErsatzTV.Application/Playouts/Commands/CreatePlayoutHandler.cs index 5d996e8fe..ef4065b44 100644 --- a/ErsatzTV.Application/Playouts/Commands/CreatePlayoutHandler.cs +++ b/ErsatzTV.Application/Playouts/Commands/CreatePlayoutHandler.cs @@ -1,4 +1,5 @@ -using System.Threading; +using System.Linq; +using System.Threading; using System.Threading.Channels; using System.Threading.Tasks; using ErsatzTV.Core; @@ -62,8 +63,14 @@ namespace ErsatzTV.Application.Playouts.Commands private async Task> ProgramScheduleMustExist( CreatePlayout createPlayout) => - (await _programScheduleRepository.Get(createPlayout.ProgramScheduleId)) - .ToValidation("ProgramSchedule does not exist."); + (await _programScheduleRepository.GetWithPlayouts(createPlayout.ProgramScheduleId)) + .ToValidation("ProgramSchedule does not exist.") + .Bind(ProgramScheduleMustHaveItems); + + private Validation ProgramScheduleMustHaveItems(ProgramSchedule programSchedule) => + Optional(programSchedule) + .Filter(ps => ps.Items.Any()) + .ToValidation("Program schedule must have items"); private Validation ValidatePlayoutType(CreatePlayout createPlayout) => Optional(createPlayout.ProgramSchedulePlayoutType) diff --git a/ErsatzTV.Core.Tests/Fakes/FakeMediaCollectionRepository.cs b/ErsatzTV.Core.Tests/Fakes/FakeMediaCollectionRepository.cs index be4e22d7b..45f90a965 100644 --- a/ErsatzTV.Core.Tests/Fakes/FakeMediaCollectionRepository.cs +++ b/ErsatzTV.Core.Tests/Fakes/FakeMediaCollectionRepository.cs @@ -45,7 +45,7 @@ namespace ErsatzTV.Core.Tests.Fakes public Task Update(SimpleMediaCollection collection) => throw new NotSupportedException(); - public Task InsertOrIgnore(TelevisionMediaCollection collection) => throw new NotSupportedException(); + public Task InsertOrIgnore(TelevisionMediaCollection collection) => throw new NotSupportedException(); public Task ReplaceItems(int collectionId, List mediaItems) => throw new NotSupportedException(); diff --git a/ErsatzTV.Core/Interfaces/Locking/IEntityLocker.cs b/ErsatzTV.Core/Interfaces/Locking/IEntityLocker.cs new file mode 100644 index 000000000..f750db1b7 --- /dev/null +++ b/ErsatzTV.Core/Interfaces/Locking/IEntityLocker.cs @@ -0,0 +1,12 @@ +using System; + +namespace ErsatzTV.Core.Interfaces.Locking +{ + public interface IEntityLocker + { + public event EventHandler OnMediaSourceChanged; + public bool LockMediaSource(int mediaSourceId); + public bool UnlockMediaSource(int mediaSourceId); + public bool IsMediaSourceLocked(int mediaSourceId); + } +} diff --git a/ErsatzTV.Core/Interfaces/Metadata/ILocalFileSystem.cs b/ErsatzTV.Core/Interfaces/Metadata/ILocalFileSystem.cs new file mode 100644 index 000000000..10de3a11a --- /dev/null +++ b/ErsatzTV.Core/Interfaces/Metadata/ILocalFileSystem.cs @@ -0,0 +1,13 @@ +using ErsatzTV.Core.Domain; +using LanguageExt; + +namespace ErsatzTV.Core.Interfaces.Metadata +{ + public interface ILocalFileSystem + { + public bool IsMediaSourceAccessible(LocalMediaSource localMediaSource); + public Seq FindRelevantVideos(LocalMediaSource localMediaSource); + public bool ShouldRefreshMetadata(LocalMediaSource localMediaSource, MediaItem mediaItem); + public bool ShouldRefreshPoster(MediaItem mediaItem); + } +} diff --git a/ErsatzTV.Core/Interfaces/Metadata/ILocalMediaScanner.cs b/ErsatzTV.Core/Interfaces/Metadata/ILocalMediaScanner.cs index c0d87b6b8..4b8ed7595 100644 --- a/ErsatzTV.Core/Interfaces/Metadata/ILocalMediaScanner.cs +++ b/ErsatzTV.Core/Interfaces/Metadata/ILocalMediaScanner.cs @@ -1,11 +1,15 @@ using System.Threading.Tasks; using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Metadata; using LanguageExt; namespace ErsatzTV.Core.Interfaces.Metadata { public interface ILocalMediaScanner { - Task ScanLocalMediaSource(LocalMediaSource localMediaSource, string ffprobePath); + Task ScanLocalMediaSource( + LocalMediaSource localMediaSource, + string ffprobePath, + ScanningMode scanningMode); } } diff --git a/ErsatzTV.Core/Interfaces/Metadata/ILocalStatisticsProvider.cs b/ErsatzTV.Core/Interfaces/Metadata/ILocalStatisticsProvider.cs index 12b49d93a..c1b480c64 100644 --- a/ErsatzTV.Core/Interfaces/Metadata/ILocalStatisticsProvider.cs +++ b/ErsatzTV.Core/Interfaces/Metadata/ILocalStatisticsProvider.cs @@ -5,6 +5,6 @@ namespace ErsatzTV.Core.Interfaces.Metadata { public interface ILocalStatisticsProvider { - Task RefreshStatistics(string ffprobePath, MediaItem mediaItem); + Task RefreshStatistics(string ffprobePath, MediaItem mediaItem); } } diff --git a/ErsatzTV.Core/Interfaces/Metadata/ISmartCollectionBuilder.cs b/ErsatzTV.Core/Interfaces/Metadata/ISmartCollectionBuilder.cs index 79e83ee34..0983b5fd7 100644 --- a/ErsatzTV.Core/Interfaces/Metadata/ISmartCollectionBuilder.cs +++ b/ErsatzTV.Core/Interfaces/Metadata/ISmartCollectionBuilder.cs @@ -5,6 +5,6 @@ namespace ErsatzTV.Core.Interfaces.Metadata { public interface ISmartCollectionBuilder { - Task RefreshSmartCollections(MediaItem mediaItem); + Task RefreshSmartCollections(MediaItem mediaItem); } } diff --git a/ErsatzTV.Core/Interfaces/Repositories/IMediaCollectionRepository.cs b/ErsatzTV.Core/Interfaces/Repositories/IMediaCollectionRepository.cs index afc866430..67e2ccc97 100644 --- a/ErsatzTV.Core/Interfaces/Repositories/IMediaCollectionRepository.cs +++ b/ErsatzTV.Core/Interfaces/Repositories/IMediaCollectionRepository.cs @@ -20,7 +20,7 @@ namespace ErsatzTV.Core.Interfaces.Repositories public Task>> GetSimpleMediaCollectionItems(int id); public Task>> GetTelevisionMediaCollectionItems(int id); public Task Update(SimpleMediaCollection collection); - public Task InsertOrIgnore(TelevisionMediaCollection collection); + public Task InsertOrIgnore(TelevisionMediaCollection collection); public Task ReplaceItems(int collectionId, List mediaItems); public Task Delete(int mediaCollectionId); public Task DeleteEmptyTelevisionCollections(); diff --git a/ErsatzTV.Core/Interfaces/Repositories/IMediaItemRepository.cs b/ErsatzTV.Core/Interfaces/Repositories/IMediaItemRepository.cs index 1f09bb9a7..513ef76e9 100644 --- a/ErsatzTV.Core/Interfaces/Repositories/IMediaItemRepository.cs +++ b/ErsatzTV.Core/Interfaces/Repositories/IMediaItemRepository.cs @@ -15,7 +15,7 @@ namespace ErsatzTV.Core.Interfaces.Repositories public Task> GetPageByType(MediaType mediaType, int pageNumber, int pageSize); public Task GetCountByType(MediaType mediaType); public Task> GetAllByMediaSourceId(int mediaSourceId); - public Task Update(MediaItem mediaItem); + public Task Update(MediaItem mediaItem); public Task Delete(int mediaItemId); } } diff --git a/ErsatzTV.Core/Metadata/LocalFileSystem.cs b/ErsatzTV.Core/Metadata/LocalFileSystem.cs new file mode 100644 index 000000000..74153e6bb --- /dev/null +++ b/ErsatzTV.Core/Metadata/LocalFileSystem.cs @@ -0,0 +1,64 @@ +using System; +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 +{ + public class LocalFileSystem : ILocalFileSystem + { + public bool IsMediaSourceAccessible(LocalMediaSource localMediaSource) => + Directory.Exists(localMediaSource.Folder); + + public Seq FindRelevantVideos(LocalMediaSource localMediaSource) + { + 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(ShouldExcludeDirectory); + 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)) + .Filter(file => KnownExtensions.Contains(Path.GetExtension(file))) + .OrderBy(identity) + .ToSeq(); + } + + public bool ShouldRefreshMetadata(LocalMediaSource localMediaSource, MediaItem mediaItem) + { + DateTime lastWrite = File.GetLastWriteTimeUtc(mediaItem.Path); + bool modified = lastWrite > mediaItem.LastWriteTime.IfNone(DateTime.MinValue); + return modified // media item has been modified + || mediaItem.Metadata == null // media item has no metadata + || mediaItem.Metadata.MediaType != localMediaSource.MediaType; // media item is typed incorrectly + } + + public bool ShouldRefreshPoster(MediaItem mediaItem) => + string.IsNullOrWhiteSpace(mediaItem.Poster); + + private static bool ShouldExcludeDirectory(string path) => File.Exists(Path.Combine(path, ".etvignore")); + + // see https://support.emby.media/support/solutions/articles/44001159102-movie-naming + private static bool IsExtrasFolder(string path) => + ExtraFolderNames.Contains(Path.GetFileName(path)?.ToLowerInvariant()); + + // @formatter:off + private static readonly Seq KnownExtensions = Seq( + ".mpg", ".mp2", ".mpeg", ".mpe", ".mpv", ".ogg", ".mp4", + ".m4p", ".m4v", ".avi", ".wmv", ".mov", ".mkv", ".ts"); + + private static readonly Seq ExtraFolderNames = Seq( + "extras", "specials", "shorts", "scenes", "featurettes", + "behind the scenes", "deleted scenes", "interviews", "trailers"); + // @formatter:on + } +} diff --git a/ErsatzTV.Core/Metadata/LocalMediaScanner.cs b/ErsatzTV.Core/Metadata/LocalMediaScanner.cs index 3841ade71..448324924 100644 --- a/ErsatzTV.Core/Metadata/LocalMediaScanner.cs +++ b/ErsatzTV.Core/Metadata/LocalMediaScanner.cs @@ -1,5 +1,4 @@ -using System; -using System.Collections.Generic; +using System.Collections.Generic; using System.IO; using System.Linq; using System.Threading.Tasks; @@ -15,6 +14,7 @@ namespace ErsatzTV.Core.Metadata { public class LocalMediaScanner : ILocalMediaScanner { + private readonly ILocalFileSystem _localFileSystem; private readonly ILocalMetadataProvider _localMetadataProvider; private readonly ILocalPosterProvider _localPosterProvider; private readonly ILocalStatisticsProvider _localStatisticsProvider; @@ -32,7 +32,8 @@ namespace ErsatzTV.Core.Metadata ILocalPosterProvider localPosterProvider, ISmartCollectionBuilder smartCollectionBuilder, IPlayoutBuilder playoutBuilder, - ILogger logger) + ILogger logger, + ILocalFileSystem localFileSystem) { _mediaItemRepository = mediaItemRepository; _playoutRepository = playoutRepository; @@ -42,78 +43,45 @@ namespace ErsatzTV.Core.Metadata _smartCollectionBuilder = smartCollectionBuilder; _playoutBuilder = playoutBuilder; _logger = logger; + _localFileSystem = localFileSystem; } - public async Task ScanLocalMediaSource(LocalMediaSource localMediaSource, string ffprobePath) + public async Task ScanLocalMediaSource( + LocalMediaSource localMediaSource, + string ffprobePath, + ScanningMode scanningMode) { - if (!Directory.Exists(localMediaSource.Folder)) + if (!_localFileSystem.IsMediaSourceAccessible(localMediaSource)) { _logger.LogWarning( - "Media source folder {Folder} does not exist; skipping scan", + "Media source folder {Folder} does not exist or is inaccessible; skipping scan", localMediaSource.Folder); - return Unit.Default; + return unit; } List knownMediaItems = await _mediaItemRepository.GetAllByMediaSourceId(localMediaSource.Id); var modifiedPlayoutIds = new List(); - // remove files that no longer exist - // add new files - // refresh metadata for any files where it is missing - var knownExtensions = new List - { - ".mpg", ".mp2", ".mpeg", ".mpe", ".mpv", ".ogg", ".mp4", ".m4p", ".m4v", - ".avi", ".wmv", ".mov", ".mkv", ".ts" - }; - - 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(ShouldExcludeDirectory); - Seq relevantDirectories = allDirectories - .Filter(d => !excluded.Any(d.StartsWith)); - - var allFiles = relevantDirectories - .Collect(d => Directory.GetFiles(d, "*", SearchOption.TopDirectoryOnly)) - .Filter(file => knownExtensions.Contains(Path.GetExtension(file))) - .OrderBy(identity) - .ToSeq(); + Seq allFiles = _localFileSystem.FindRelevantVideos(localMediaSource); // check if the media item exists (Seq newFiles, Seq existingMediaItems) = allFiles.Map( s => Optional(knownMediaItems.Find(i => i.Path == s)).ToEither(s)) .Partition(); - // TODO: flag as missing? delete after some period of time? - var removedMediaItems = knownMediaItems.Filter(i => !allFiles.Contains(i.Path)).ToSeq(); - modifiedPlayoutIds.AddRange(await _playoutRepository.GetPlayoutIdsForMediaItems(removedMediaItems)); - foreach (MediaItem mediaItem in removedMediaItems) - { - _logger.LogDebug("Removing missing local media item {MediaItem}", mediaItem.Path); - await _mediaItemRepository.Delete(mediaItem.Id); - } + // 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)); - // if exists, check if the file was modified - // also, try to re-categorize incorrect media types by refreshing metadata - Seq modifiedMediaItems = existingMediaItems.Filter( - mediaItem => - { - DateTime lastWrite = File.GetLastWriteTimeUtc(mediaItem.Path); - bool modified = lastWrite > mediaItem.LastWriteTime.IfNone(DateTime.MinValue); - return modified || mediaItem.Metadata == null || - mediaItem.Metadata.MediaType != localMediaSource.MediaType; - }); + 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)); - foreach (MediaItem mediaItem in modifiedMediaItems) - { - _logger.LogDebug("Refreshing metadata for media item {MediaItem}", mediaItem.Path); - await RefreshMetadata(mediaItem, ffprobePath); - } // if new, add and store mtime, refresh metadata - var addedMediaItems = new Seq(); + var addedMediaItems = new List(); foreach (string path in newFiles) { _logger.LogDebug("Adding new media item {MediaItem}", path); @@ -129,7 +97,12 @@ namespace ErsatzTV.Core.Metadata addedMediaItems.Add(mediaItem); } - modifiedPlayoutIds.AddRange(await _playoutRepository.GetPlayoutIdsForMediaItems(addedMediaItems)); + modifiedPlayoutIds.AddRange(await _playoutRepository.GetPlayoutIdsForMediaItems(addedMediaItems.ToSeq())); + + Seq stalePosterMediaItems = existingMediaItems + .Filter(_localFileSystem.ShouldRefreshPoster) + .Concat(addedMediaItems); + await RefreshPosterForItems(stalePosterMediaItems); foreach (int playoutId in modifiedPlayoutIds.Distinct()) { @@ -143,17 +116,68 @@ namespace ErsatzTV.Core.Metadata Task.CompletedTask); } - return Unit.Default; + return unit; } - private async Task RefreshMetadata(MediaItem mediaItem, string ffprobePath) + private async Task> RefreshMetadataForItems( + string ffprobePath, + Seq staleMetadataMediaItems) { - await _localStatisticsProvider.RefreshStatistics(ffprobePath, mediaItem); - await _localMetadataProvider.RefreshMetadata(mediaItem); - await _localPosterProvider.RefreshPoster(mediaItem); - await _smartCollectionBuilder.RefreshSmartCollections(mediaItem); + var modifiedMediaItems = new List(); + foreach (MediaItem mediaItem in staleMetadataMediaItems) + { + _logger.LogDebug("Refreshing metadata for media item {MediaItem}", mediaItem.Path); + if (await RefreshMetadata(mediaItem, ffprobePath)) + { + // only queue playout rebuilds for media items + // where the duration or collections have changed + modifiedMediaItems.Add(mediaItem); + } + } + + return modifiedMediaItems.ToSeq(); } - private static bool ShouldExcludeDirectory(string path) => File.Exists(Path.Combine(path, ".etvignore")); + private async Task RefreshPosterForItems(Seq stalePosterMediaItems) + { + (Seq movies, Seq episodes) = stalePosterMediaItems + .Map(i => Optional(i).Filter(i2 => i2.Metadata?.MediaType == MediaType.TvShow).ToEither(i)) + .Partition(); + + // there's a 1:1 movie:poster, so refresh all + foreach (MediaItem movie in movies) + { + _logger.LogDebug("Refreshing poster for media item {MediaItem}", movie.Path); + await _localPosterProvider.RefreshPoster(movie); + } + + // 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()); + + foreach (MediaItem episode in episodesToRefresh) + { + _logger.LogDebug("Refreshing poster for media item {MediaItem}", episode.Path); + await _localPosterProvider.RefreshPoster(episode); + } + } + + private async Task RemoveMissingItems(Seq removedMediaItems) + { + // TODO: flag as missing? delete after some period of time? + foreach (MediaItem mediaItem in removedMediaItems) + { + _logger.LogDebug("Removing missing local media item {MediaItem}", mediaItem.Path); + await _mediaItemRepository.Delete(mediaItem.Id); + } + } + + private async Task RefreshMetadata(MediaItem mediaItem, string ffprobePath) + { + bool durationChange = await _localStatisticsProvider.RefreshStatistics(ffprobePath, mediaItem); + await _localMetadataProvider.RefreshMetadata(mediaItem); + bool collectionChange = await _smartCollectionBuilder.RefreshSmartCollections(mediaItem); + return durationChange || collectionChange; + } } } diff --git a/ErsatzTV.Core/Metadata/LocalPosterProvider.cs b/ErsatzTV.Core/Metadata/LocalPosterProvider.cs index 800fd94b4..1e5012ad3 100644 --- a/ErsatzTV.Core/Metadata/LocalPosterProvider.cs +++ b/ErsatzTV.Core/Metadata/LocalPosterProvider.cs @@ -1,4 +1,5 @@ -using System.IO; +using System.Collections.Generic; +using System.IO; using System.Linq; using System.Threading.Tasks; using ErsatzTV.Core.Domain; @@ -13,6 +14,8 @@ namespace ErsatzTV.Core.Metadata { public class LocalPosterProvider : ILocalPosterProvider { + private static readonly string[] SupportedExtensions = { "jpg", "jpeg", "png", "gif", "tbn" }; + private readonly IImageCache _imageCache; private readonly ILogger _logger; private readonly IMediaItemRepository _mediaItemRepository; @@ -46,8 +49,8 @@ namespace ErsatzTV.Core.Metadata string folder = Path.GetDirectoryName(mediaItem.Path); if (folder != null) { - string[] possiblePaths = - { "poster.jpg", Path.GetFileNameWithoutExtension(mediaItem.Path) + "-poster.jpg" }; + IEnumerable possiblePaths = SupportedExtensions.Collect( + e => new[] { $"poster.{e}", Path.GetFileNameWithoutExtension(mediaItem.Path) + $"-poster.{e}" }); Option maybePoster = possiblePaths.Map(p => Path.Combine(folder, p)).FirstOrDefault(File.Exists); return maybePoster; @@ -61,7 +64,7 @@ namespace ErsatzTV.Core.Metadata string folder = Directory.GetParent(Path.GetDirectoryName(mediaItem.Path) ?? string.Empty)?.FullName; if (folder != null) { - string[] possiblePaths = { "poster.jpg" }; + IEnumerable possiblePaths = SupportedExtensions.Collect(e => new[] { $"poster.{e}" }); Option maybePoster = possiblePaths.Map(p => Path.Combine(folder, p)).FirstOrDefault(File.Exists); return maybePoster; diff --git a/ErsatzTV.Core/Metadata/LocalStatisticsProvider.cs b/ErsatzTV.Core/Metadata/LocalStatisticsProvider.cs index 745b49a77..9d1fc2298 100644 --- a/ErsatzTV.Core/Metadata/LocalStatisticsProvider.cs +++ b/ErsatzTV.Core/Metadata/LocalStatisticsProvider.cs @@ -26,21 +26,22 @@ namespace ErsatzTV.Core.Metadata _logger = logger; } - public async Task RefreshStatistics(string ffprobePath, MediaItem mediaItem) + public async Task RefreshStatistics(string ffprobePath, MediaItem mediaItem) { try { FFprobe ffprobe = await GetProbeOutput(ffprobePath, mediaItem); MediaMetadata metadata = ProjectToMediaMetadata(ffprobe); - await ApplyStatisticsUpdate(mediaItem, metadata); + return await ApplyStatisticsUpdate(mediaItem, metadata); } catch (Exception ex) { _logger.LogWarning(ex, "Failed to refresh statistics for media item at {Path}", mediaItem.Path); + return false; } } - private async Task ApplyStatisticsUpdate( + private async Task ApplyStatisticsUpdate( MediaItem mediaItem, MediaMetadata metadata) { @@ -49,6 +50,8 @@ namespace ErsatzTV.Core.Metadata mediaItem.Metadata = new MediaMetadata(); } + bool durationChange = mediaItem.Metadata.Duration != metadata.Duration; + mediaItem.Metadata.Duration = metadata.Duration; mediaItem.Metadata.AudioCodec = metadata.AudioCodec; mediaItem.Metadata.SampleAspectRatio = metadata.SampleAspectRatio; @@ -58,7 +61,7 @@ namespace ErsatzTV.Core.Metadata mediaItem.Metadata.VideoCodec = metadata.VideoCodec; mediaItem.Metadata.VideoScanType = metadata.VideoScanType; - await _mediaItemRepository.Update(mediaItem); + return await _mediaItemRepository.Update(mediaItem) && durationChange; } private Task GetProbeOutput(string ffprobePath, MediaItem mediaItem) diff --git a/ErsatzTV.Core/Metadata/ScanningMode.cs b/ErsatzTV.Core/Metadata/ScanningMode.cs new file mode 100644 index 000000000..e38fd6b37 --- /dev/null +++ b/ErsatzTV.Core/Metadata/ScanningMode.cs @@ -0,0 +1,8 @@ +namespace ErsatzTV.Core.Metadata +{ + public enum ScanningMode + { + Default = 0, + RescanAll = 1 + } +} diff --git a/ErsatzTV.Core/Metadata/SmartCollectionBuilder.cs b/ErsatzTV.Core/Metadata/SmartCollectionBuilder.cs index 7150e968f..db84fa647 100644 --- a/ErsatzTV.Core/Metadata/SmartCollectionBuilder.cs +++ b/ErsatzTV.Core/Metadata/SmartCollectionBuilder.cs @@ -16,12 +16,16 @@ namespace ErsatzTV.Core.Metadata public SmartCollectionBuilder(IMediaCollectionRepository mediaCollectionRepository) => _mediaCollectionRepository = mediaCollectionRepository; - public async Task RefreshSmartCollections(MediaItem mediaItem) + public async Task RefreshSmartCollections(MediaItem mediaItem) { + var results = new List(); + foreach (TelevisionMediaCollection collection in GetTelevisionCollections(mediaItem)) { - await _mediaCollectionRepository.InsertOrIgnore(collection); + results.Add(await _mediaCollectionRepository.InsertOrIgnore(collection)); } + + return results.Any(identity); } private IEnumerable GetTelevisionCollections(MediaItem mediaItem) diff --git a/ErsatzTV.Infrastructure/Data/Repositories/MediaCollectionRepository.cs b/ErsatzTV.Infrastructure/Data/Repositories/MediaCollectionRepository.cs index 9f532bea6..664254510 100644 --- a/ErsatzTV.Infrastructure/Data/Repositories/MediaCollectionRepository.cs +++ b/ErsatzTV.Infrastructure/Data/Repositories/MediaCollectionRepository.cs @@ -85,15 +85,18 @@ namespace ErsatzTV.Infrastructure.Data.Repositories return _dbContext.SaveChangesAsync(); } - public async Task InsertOrIgnore(TelevisionMediaCollection collection) + public async Task InsertOrIgnore(TelevisionMediaCollection collection) { if (!_dbContext.TelevisionMediaCollections.Any( existing => existing.ShowTitle == collection.ShowTitle && existing.SeasonNumber == collection.SeasonNumber)) { await _dbContext.TelevisionMediaCollections.AddAsync(collection); - await _dbContext.SaveChangesAsync(); + return await _dbContext.SaveChangesAsync() > 0; } + + // no change + return false; } public Task ReplaceItems(int collectionId, List mediaItems) => diff --git a/ErsatzTV.Infrastructure/Data/Repositories/MediaItemRepository.cs b/ErsatzTV.Infrastructure/Data/Repositories/MediaItemRepository.cs index 83c9c7a6f..b419ff7d0 100644 --- a/ErsatzTV.Infrastructure/Data/Repositories/MediaItemRepository.cs +++ b/ErsatzTV.Infrastructure/Data/Repositories/MediaItemRepository.cs @@ -97,10 +97,10 @@ LIMIT {0} OFFSET {1}", .Filter(i => i.MediaSourceId == mediaSourceId) .ToListAsync(); - public async Task Update(MediaItem mediaItem) + public async Task Update(MediaItem mediaItem) { _dbContext.MediaItems.Update(mediaItem); - await _dbContext.SaveChangesAsync(); + return await _dbContext.SaveChangesAsync() > 0; } public async Task Delete(int mediaItemId) diff --git a/ErsatzTV.Infrastructure/Locking/EntityLocker.cs b/ErsatzTV.Infrastructure/Locking/EntityLocker.cs new file mode 100644 index 000000000..26122230b --- /dev/null +++ b/ErsatzTV.Infrastructure/Locking/EntityLocker.cs @@ -0,0 +1,40 @@ +using System; +using System.Collections.Concurrent; +using ErsatzTV.Core.Interfaces.Locking; + +namespace ErsatzTV.Infrastructure.Locking +{ + public class EntityLocker : IEntityLocker + { + private readonly ConcurrentDictionary _lockedMediaSources; + + public EntityLocker() => _lockedMediaSources = new ConcurrentDictionary(); + + public event EventHandler OnMediaSourceChanged; + + public bool LockMediaSource(int mediaSourceId) + { + if (!_lockedMediaSources.ContainsKey(mediaSourceId) && _lockedMediaSources.TryAdd(mediaSourceId, 0)) + { + OnMediaSourceChanged?.Invoke(this, EventArgs.Empty); + return true; + } + + return false; + } + + public bool UnlockMediaSource(int mediaSourceId) + { + if (_lockedMediaSources.TryRemove(mediaSourceId, out byte _)) + { + OnMediaSourceChanged?.Invoke(this, EventArgs.Empty); + return true; + } + + return false; + } + + public bool IsMediaSourceLocked(int mediaSourceId) => + _lockedMediaSources.ContainsKey(mediaSourceId); + } +} diff --git a/ErsatzTV.sln.DotSettings b/ErsatzTV.sln.DotSettings index b999bc941..59acb62f7 100644 --- a/ErsatzTV.sln.DotSettings +++ b/ErsatzTV.sln.DotSettings @@ -13,6 +13,7 @@ True True True + True True True True diff --git a/ErsatzTV/Pages/LocalMediaSourceEditor.razor b/ErsatzTV/Pages/LocalMediaSourceEditor.razor index db61326b3..de192ddbe 100644 --- a/ErsatzTV/Pages/LocalMediaSourceEditor.razor +++ b/ErsatzTV/Pages/LocalMediaSourceEditor.razor @@ -1,10 +1,12 @@ @page "/media/sources/local/add" @using ErsatzTV.Application.MediaSources.Commands @using ErsatzTV.Application.MediaSources +@using ErsatzTV.Core.Metadata @inject NavigationManager NavigationManager @inject ILogger Logger @inject ISnackbar Snackbar @inject IMediator Mediator +@inject IEntityLocker Locker @inject ChannelWriter Channel
@@ -84,8 +86,11 @@ }, Right: async vm => { - await Channel.WriteAsync(new ScanLocalMediaSource(vm.Id)); - NavigationManager.NavigateTo("/media/sources"); + if (Locker.LockMediaSource(vm.Id)) + { + await Channel.WriteAsync(new ScanLocalMediaSource(vm.Id, ScanningMode.Default)); + NavigationManager.NavigateTo("/media/sources"); + } }); } } diff --git a/ErsatzTV/Pages/PlayoutEditor.razor b/ErsatzTV/Pages/PlayoutEditor.razor index edb74cb55..861fc1e8d 100644 --- a/ErsatzTV/Pages/PlayoutEditor.razor +++ b/ErsatzTV/Pages/PlayoutEditor.razor @@ -65,7 +65,7 @@ errorMessage.HeadOrNone().Match( error => { - Snackbar.Add(error.Value); + Snackbar.Add(error.Value, Severity.Error); Logger.LogError("Unexpected error saving playout: {Error}", error.Value); }, () => NavigationManager.NavigateTo("/playouts")); diff --git a/ErsatzTV/Services/SchedulerService.cs b/ErsatzTV/Services/SchedulerService.cs index 86e9cd1a4..8b91872ba 100644 --- a/ErsatzTV/Services/SchedulerService.cs +++ b/ErsatzTV/Services/SchedulerService.cs @@ -6,6 +6,8 @@ using System.Threading.Tasks; using ErsatzTV.Application; using ErsatzTV.Application.MediaSources.Commands; using ErsatzTV.Application.Playouts.Commands; +using ErsatzTV.Core.Interfaces.Locking; +using ErsatzTV.Core.Metadata; using ErsatzTV.Infrastructure.Data; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; @@ -16,15 +18,18 @@ namespace ErsatzTV.Services public class SchedulerService : IHostedService { private readonly ChannelWriter _channel; + private readonly IEntityLocker _entityLocker; private readonly IServiceScopeFactory _serviceScopeFactory; private Timer _timer; public SchedulerService( IServiceScopeFactory serviceScopeFactory, - ChannelWriter channel) + ChannelWriter channel, + IEntityLocker entityLocker) { _serviceScopeFactory = serviceScopeFactory; _channel = channel; + _entityLocker = entityLocker; } public Task StartAsync(CancellationToken cancellationToken) @@ -75,7 +80,12 @@ namespace ErsatzTV.Services foreach (int mediaSourceId in localMediaSourceIds) { - await _channel.WriteAsync(new ScanLocalMediaSource(mediaSourceId), cancellationToken); + if (_entityLocker.LockMediaSource(mediaSourceId)) + { + await _channel.WriteAsync( + new ScanLocalMediaSource(mediaSourceId, ScanningMode.Default), + cancellationToken); + } } } } diff --git a/ErsatzTV/Shared/LocalMediaSources.razor b/ErsatzTV/Shared/LocalMediaSources.razor index f486ec440..07c60f4bb 100644 --- a/ErsatzTV/Shared/LocalMediaSources.razor +++ b/ErsatzTV/Shared/LocalMediaSources.razor @@ -1,16 +1,20 @@ @using ErsatzTV.Application.MediaSources @using ErsatzTV.Application.MediaSources.Commands @using ErsatzTV.Application.MediaSources.Queries +@using ErsatzTV.Core.Metadata +@implements IDisposable @inject IDialogService Dialog @inject IMediator Mediator +@inject IEntityLocker Locker +@inject ChannelWriter Channel - + Local Media Sources - + Folder @@ -19,7 +23,29 @@ @context.Folder - +
+ @if (Locker.IsMediaSourceLocked(context.Id)) + { +
+ +
+ } + else + { + + + + + } + + + + +
@@ -30,6 +56,9 @@ @code { private IList _mediaSources; + protected override void OnInitialized() => + Locker.OnMediaSourceChanged += LockChanged; + protected override async Task OnParametersSetAsync() => await LoadMediaSources(); private async Task LoadMediaSources() => @@ -57,4 +86,18 @@ } } + private async Task RefreshAllMetadata(LocalMediaSourceViewModel mediaSource) + { + if (Locker.LockMediaSource(mediaSource.Id)) + { + await Channel.WriteAsync(new ScanLocalMediaSource(mediaSource.Id, ScanningMode.RescanAll)); + StateHasChanged(); + } + } + + private void LockChanged(object sender, EventArgs e) => + InvokeAsync(StateHasChanged); + + void IDisposable.Dispose() => Locker.OnMediaSourceChanged -= LockChanged; + } \ No newline at end of file diff --git a/ErsatzTV/Startup.cs b/ErsatzTV/Startup.cs index 212a89df7..f3ffd9c96 100644 --- a/ErsatzTV/Startup.cs +++ b/ErsatzTV/Startup.cs @@ -1,5 +1,6 @@ using System; using System.IO; +using System.Reflection; using System.Threading.Channels; using ErsatzTV.Application; using ErsatzTV.Application.Channels.Queries; @@ -7,6 +8,7 @@ using ErsatzTV.Core; using ErsatzTV.Core.FFmpeg; using ErsatzTV.Core.Interfaces.FFmpeg; using ErsatzTV.Core.Interfaces.Images; +using ErsatzTV.Core.Interfaces.Locking; using ErsatzTV.Core.Interfaces.Metadata; using ErsatzTV.Core.Interfaces.Plex; using ErsatzTV.Core.Interfaces.Repositories; @@ -17,6 +19,7 @@ using ErsatzTV.Formatters; using ErsatzTV.Infrastructure.Data; using ErsatzTV.Infrastructure.Data.Repositories; using ErsatzTV.Infrastructure.Images; +using ErsatzTV.Infrastructure.Locking; using ErsatzTV.Infrastructure.Plex; using ErsatzTV.Serialization; using ErsatzTV.Services; @@ -77,6 +80,11 @@ namespace ErsatzTV services.AddMudServices(); + Log.Logger.Information( + "ErsatzTV version {Version}", + Assembly.GetEntryAssembly().GetCustomAttribute() + ?.InformationalVersion ?? "unknown"); + Log.Logger.Warning("This is pre-alpha software and is likely to be unstable"); Log.Logger.Warning( "Give feedback at {GitHub} or {Discord}", @@ -149,6 +157,7 @@ namespace ErsatzTV services.AddSingleton(); services.AddSingleton(); // TODO: does this need to be singleton? services.AddSingleton(); + services.AddSingleton(); AddChannel(services); AddChannel(services); @@ -170,6 +179,7 @@ namespace ErsatzTV services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddHostedService(); services.AddHostedService(); diff --git a/ErsatzTV/_Imports.razor b/ErsatzTV/_Imports.razor index fad2bdf07..f6dc56d31 100644 --- a/ErsatzTV/_Imports.razor +++ b/ErsatzTV/_Imports.razor @@ -20,6 +20,7 @@ @using ErsatzTV.Application @using ErsatzTV.Core @using ErsatzTV.Core.Domain +@using ErsatzTV.Core.Interfaces.Locking @using ErsatzTV.Infrastructure.Data @using ErsatzTV.Shared @using ErsatzTV.ViewModels \ No newline at end of file