diff --git a/ErsatzTV.Application/MediaSources/Commands/ScanLocalMediaSource.cs b/ErsatzTV.Application/MediaSources/Commands/ScanLocalMediaSource.cs index e4d5debd5..818949a2d 100644 --- a/ErsatzTV.Application/MediaSources/Commands/ScanLocalMediaSource.cs +++ b/ErsatzTV.Application/MediaSources/Commands/ScanLocalMediaSource.cs @@ -1,10 +1,11 @@ using ErsatzTV.Core; +using ErsatzTV.Core.Metadata; using LanguageExt; using MediatR; namespace ErsatzTV.Application.MediaSources.Commands { - public record ScanLocalMediaSource(int MediaSourceId, bool RefreshAllMetadata) : + 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 76ff69ac3..655f5df9a 100644 --- a/ErsatzTV.Application/MediaSources/Commands/ScanLocalMediaSourceHandler.cs +++ b/ErsatzTV.Application/MediaSources/Commands/ScanLocalMediaSourceHandler.cs @@ -42,7 +42,7 @@ namespace ErsatzTV.Application.MediaSources.Commands await _localMediaScanner.ScanLocalMediaSource( parameters.LocalMediaSource, parameters.FFprobePath, - request.RefreshAllMetadata); + request.ScanningMode); _entityLocker.UnlockMediaSource(parameters.LocalMediaSource.Id); 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/Metadata/ILocalMediaScanner.cs b/ErsatzTV.Core/Interfaces/Metadata/ILocalMediaScanner.cs index 160e20d03..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, bool refreshAllMetadata); + 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/LocalMediaScanner.cs b/ErsatzTV.Core/Metadata/LocalMediaScanner.cs index 07b812b1e..f3c5fac34 100644 --- a/ErsatzTV.Core/Metadata/LocalMediaScanner.cs +++ b/ErsatzTV.Core/Metadata/LocalMediaScanner.cs @@ -47,7 +47,7 @@ namespace ErsatzTV.Core.Metadata public async Task ScanLocalMediaSource( LocalMediaSource localMediaSource, string ffprobePath, - bool refreshAllMetadata) + ScanningMode scanningMode) { if (!Directory.Exists(localMediaSource.Folder)) { @@ -100,7 +100,7 @@ namespace ErsatzTV.Core.Metadata // if exists, check if the file was modified // also, try to re-categorize incorrect media types by refreshing metadata - Seq modifiedMediaItems = refreshAllMetadata + Seq mediaItemsToScan = scanningMode == ScanningMode.RescanAll ? existingMediaItems : existingMediaItems.Filter( mediaItem => @@ -110,13 +110,22 @@ namespace ErsatzTV.Core.Metadata return modified || mediaItem.Metadata == null || mediaItem.Metadata.MediaType != localMediaSource.MediaType; }); - modifiedPlayoutIds.AddRange(await _playoutRepository.GetPlayoutIdsForMediaItems(modifiedMediaItems)); - foreach (MediaItem mediaItem in modifiedMediaItems) + + var modifiedMediaItems = new Seq(); + foreach (MediaItem mediaItem in mediaItemsToScan) { _logger.LogDebug("Refreshing metadata for media item {MediaItem}", mediaItem.Path); - await RefreshMetadata(mediaItem, ffprobePath); + + if (await RefreshMetadata(mediaItem, ffprobePath)) + { + // only queue playout rebuilds for media items + // where the duration or collections have changed + modifiedMediaItems.Add(mediaItem); + } } + modifiedPlayoutIds.AddRange(await _playoutRepository.GetPlayoutIdsForMediaItems(modifiedMediaItems)); + // if new, add and store mtime, refresh metadata var addedMediaItems = new Seq(); foreach (string path in newFiles) @@ -151,12 +160,13 @@ namespace ErsatzTV.Core.Metadata return Unit.Default; } - private async Task RefreshMetadata(MediaItem mediaItem, string ffprobePath) + private async Task RefreshMetadata(MediaItem mediaItem, string ffprobePath) { - await _localStatisticsProvider.RefreshStatistics(ffprobePath, mediaItem); + bool durationChange = await _localStatisticsProvider.RefreshStatistics(ffprobePath, mediaItem); await _localMetadataProvider.RefreshMetadata(mediaItem); await _localPosterProvider.RefreshPoster(mediaItem); - await _smartCollectionBuilder.RefreshSmartCollections(mediaItem); + bool collectionChange = await _smartCollectionBuilder.RefreshSmartCollections(mediaItem); + return durationChange || collectionChange; } private static bool ShouldExcludeDirectory(string path) => File.Exists(Path.Combine(path, ".etvignore")); 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 index 6cfcd2200..26122230b 100644 --- a/ErsatzTV.Infrastructure/Locking/EntityLocker.cs +++ b/ErsatzTV.Infrastructure/Locking/EntityLocker.cs @@ -14,7 +14,7 @@ namespace ErsatzTV.Infrastructure.Locking public bool LockMediaSource(int mediaSourceId) { - if (_lockedMediaSources.TryAdd(mediaSourceId, 0)) + if (!_lockedMediaSources.ContainsKey(mediaSourceId) && _lockedMediaSources.TryAdd(mediaSourceId, 0)) { OnMediaSourceChanged?.Invoke(this, EventArgs.Empty); return true; diff --git a/ErsatzTV/Pages/LocalMediaSourceEditor.razor b/ErsatzTV/Pages/LocalMediaSourceEditor.razor index a36599d29..de192ddbe 100644 --- a/ErsatzTV/Pages/LocalMediaSourceEditor.razor +++ b/ErsatzTV/Pages/LocalMediaSourceEditor.razor @@ -1,6 +1,7 @@ @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 @@ -87,7 +88,7 @@ { if (Locker.LockMediaSource(vm.Id)) { - await Channel.WriteAsync(new ScanLocalMediaSource(vm.Id, false)); + 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 3ea11ebb7..8b91872ba 100644 --- a/ErsatzTV/Services/SchedulerService.cs +++ b/ErsatzTV/Services/SchedulerService.cs @@ -7,6 +7,7 @@ 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; @@ -81,7 +82,9 @@ namespace ErsatzTV.Services { if (_entityLocker.LockMediaSource(mediaSourceId)) { - await _channel.WriteAsync(new ScanLocalMediaSource(mediaSourceId, false), cancellationToken); + await _channel.WriteAsync( + new ScanLocalMediaSource(mediaSourceId, ScanningMode.Default), + cancellationToken); } } } diff --git a/ErsatzTV/Shared/LocalMediaSources.razor b/ErsatzTV/Shared/LocalMediaSources.razor index 39ba78d69..07c60f4bb 100644 --- a/ErsatzTV/Shared/LocalMediaSources.razor +++ b/ErsatzTV/Shared/LocalMediaSources.razor @@ -1,6 +1,7 @@ @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 @@ -89,7 +90,7 @@ { if (Locker.LockMediaSource(mediaSource.Id)) { - await Channel.WriteAsync(new ScanLocalMediaSource(mediaSource.Id, true)); + await Channel.WriteAsync(new ScanLocalMediaSource(mediaSource.Id, ScanningMode.RescanAll)); StateHasChanged(); } }