using System.Collections.Generic; using System.IO; using System.Linq; using System.Threading.Tasks; using ErsatzTV.Core.Domain; 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; namespace ErsatzTV.Core.Metadata { public class LocalMediaScanner : ILocalMediaScanner { private readonly ILocalFileSystem _localFileSystem; private readonly ILocalMetadataProvider _localMetadataProvider; private readonly ILocalPosterProvider _localPosterProvider; private readonly ILocalStatisticsProvider _localStatisticsProvider; private readonly ILogger _logger; private readonly IMediaItemRepository _mediaItemRepository; private readonly IPlayoutBuilder _playoutBuilder; private readonly IPlayoutRepository _playoutRepository; private readonly ISmartCollectionBuilder _smartCollectionBuilder; public LocalMediaScanner( IMediaItemRepository mediaItemRepository, IPlayoutRepository playoutRepository, ILocalStatisticsProvider localStatisticsProvider, ILocalMetadataProvider localMetadataProvider, ILocalPosterProvider localPosterProvider, ISmartCollectionBuilder smartCollectionBuilder, IPlayoutBuilder playoutBuilder, ILogger logger, ILocalFileSystem localFileSystem) { _mediaItemRepository = mediaItemRepository; _playoutRepository = playoutRepository; _localStatisticsProvider = localStatisticsProvider; _localMetadataProvider = localMetadataProvider; _localPosterProvider = localPosterProvider; _smartCollectionBuilder = smartCollectionBuilder; _playoutBuilder = playoutBuilder; _logger = logger; _localFileSystem = localFileSystem; } public async Task ScanLocalMediaSource( LocalMediaSource localMediaSource, string ffprobePath, ScanningMode scanningMode) { if (!_localFileSystem.IsMediaSourceAccessible(localMediaSource)) { _logger.LogWarning( "Media source folder {Folder} does not exist or is inaccessible; skipping scan", localMediaSource.Folder); return unit; } List knownMediaItems = await _mediaItemRepository.GetAllByMediaSourceId(localMediaSource.Id); var modifiedPlayoutIds = new List(); 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(); // 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)); 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)); // 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 { MediaSourceId = localMediaSource.Id, Path = path, LastWriteTime = File.GetLastWriteTimeUtc(path) }; await _mediaItemRepository.Add(mediaItem); await RefreshMetadata(mediaItem, ffprobePath); addedMediaItems.Add(mediaItem); } modifiedPlayoutIds.AddRange(await _playoutRepository.GetPlayoutIdsForMediaItems(addedMediaItems.ToSeq())); Seq stalePosterMediaItems = existingMediaItems .Filter(_localFileSystem.ShouldRefreshPoster) .Concat(addedMediaItems); await RefreshPosterForItems(stalePosterMediaItems); foreach (int playoutId in modifiedPlayoutIds.Distinct()) { Option maybePlayout = await _playoutRepository.GetFull(playoutId); await maybePlayout.Match( async playout => { Playout result = await _playoutBuilder.BuildPlayoutItems(playout, true); await _playoutRepository.Update(result); }, Task.CompletedTask); } return unit; } private async Task> RefreshMetadataForItems( string ffprobePath, Seq staleMetadataMediaItems) { 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 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; } } }