Browse Source

scanning and poster improvements (#24)

* first pass at refresh-all-metadata by source

* add version to startup logs

* lock media source during refresh

* fix local media source "name" in collection editor

* optimize scanning so playouts only rebuild when necessary

* support more poster file types

* more scanning improvements; check for missing posters during scans
pull/27/head v0.0.8-prealpha
Jason Dove 4 years ago committed by GitHub
parent
commit
1aac2f13c9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 9
      ErsatzTV.Application/MediaItems/Mapper.cs
  2. 4
      ErsatzTV.Application/MediaSources/Commands/ScanLocalMediaSource.cs
  3. 23
      ErsatzTV.Application/MediaSources/Commands/ScanLocalMediaSourceHandler.cs
  4. 13
      ErsatzTV.Application/Playouts/Commands/CreatePlayoutHandler.cs
  5. 2
      ErsatzTV.Core.Tests/Fakes/FakeMediaCollectionRepository.cs
  6. 12
      ErsatzTV.Core/Interfaces/Locking/IEntityLocker.cs
  7. 13
      ErsatzTV.Core/Interfaces/Metadata/ILocalFileSystem.cs
  8. 6
      ErsatzTV.Core/Interfaces/Metadata/ILocalMediaScanner.cs
  9. 2
      ErsatzTV.Core/Interfaces/Metadata/ILocalStatisticsProvider.cs
  10. 2
      ErsatzTV.Core/Interfaces/Metadata/ISmartCollectionBuilder.cs
  11. 2
      ErsatzTV.Core/Interfaces/Repositories/IMediaCollectionRepository.cs
  12. 2
      ErsatzTV.Core/Interfaces/Repositories/IMediaItemRepository.cs
  13. 64
      ErsatzTV.Core/Metadata/LocalFileSystem.cs
  14. 148
      ErsatzTV.Core/Metadata/LocalMediaScanner.cs
  15. 11
      ErsatzTV.Core/Metadata/LocalPosterProvider.cs
  16. 11
      ErsatzTV.Core/Metadata/LocalStatisticsProvider.cs
  17. 8
      ErsatzTV.Core/Metadata/ScanningMode.cs
  18. 8
      ErsatzTV.Core/Metadata/SmartCollectionBuilder.cs
  19. 7
      ErsatzTV.Infrastructure/Data/Repositories/MediaCollectionRepository.cs
  20. 4
      ErsatzTV.Infrastructure/Data/Repositories/MediaItemRepository.cs
  21. 40
      ErsatzTV.Infrastructure/Locking/EntityLocker.cs
  22. 1
      ErsatzTV.sln.DotSettings
  23. 7
      ErsatzTV/Pages/LocalMediaSourceEditor.razor
  24. 2
      ErsatzTV/Pages/PlayoutEditor.razor
  25. 14
      ErsatzTV/Services/SchedulerService.cs
  26. 49
      ErsatzTV/Shared/LocalMediaSources.razor
  27. 10
      ErsatzTV/Startup.cs
  28. 1
      ErsatzTV/_Imports.razor

9
ErsatzTV.Application/MediaItems/Mapper.cs

@ -14,7 +14,7 @@ namespace ErsatzTV.Application.MediaItems @@ -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 @@ -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
};
}
}

4
ErsatzTV.Application/MediaSources/Commands/ScanLocalMediaSource.cs

@ -1,9 +1,11 @@ @@ -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<Either<BaseError, string>>,
public record ScanLocalMediaSource(int MediaSourceId, ScanningMode ScanningMode) :
IRequest<Either<BaseError, string>>,
IBackgroundServiceRequest;
}

23
ErsatzTV.Application/MediaSources/Commands/ScanLocalMediaSourceHandler.cs

@ -3,37 +3,52 @@ using System.Threading; @@ -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<ScanLocalMediaSource, Either<BaseError, string>>
{
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<Either<BaseError, string>>
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<Unit> 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<Validation<BaseError, RequestParameters>> Validate(ScanLocalMediaSource request) =>
(await LocalMediaSourceMustExist(request), await ValidateFFprobePath())
.Apply((localMediaSource, ffprobePath) => new RequestParameters(localMediaSource, ffprobePath));

13
ErsatzTV.Application/Playouts/Commands/CreatePlayoutHandler.cs

@ -1,4 +1,5 @@ @@ -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 @@ -62,8 +63,14 @@ namespace ErsatzTV.Application.Playouts.Commands
private async Task<Validation<BaseError, ProgramSchedule>> ProgramScheduleMustExist(
CreatePlayout createPlayout) =>
(await _programScheduleRepository.Get(createPlayout.ProgramScheduleId))
.ToValidation<BaseError>("ProgramSchedule does not exist.");
(await _programScheduleRepository.GetWithPlayouts(createPlayout.ProgramScheduleId))
.ToValidation<BaseError>("ProgramSchedule does not exist.")
.Bind(ProgramScheduleMustHaveItems);
private Validation<BaseError, ProgramSchedule> ProgramScheduleMustHaveItems(ProgramSchedule programSchedule) =>
Optional(programSchedule)
.Filter(ps => ps.Items.Any())
.ToValidation<BaseError>("Program schedule must have items");
private Validation<BaseError, ProgramSchedulePlayoutType> ValidatePlayoutType(CreatePlayout createPlayout) =>
Optional(createPlayout.ProgramSchedulePlayoutType)

2
ErsatzTV.Core.Tests/Fakes/FakeMediaCollectionRepository.cs

@ -45,7 +45,7 @@ namespace ErsatzTV.Core.Tests.Fakes @@ -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<bool> InsertOrIgnore(TelevisionMediaCollection collection) => throw new NotSupportedException();
public Task<Unit> ReplaceItems(int collectionId, List<MediaItem> mediaItems) =>
throw new NotSupportedException();

12
ErsatzTV.Core/Interfaces/Locking/IEntityLocker.cs

@ -0,0 +1,12 @@ @@ -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);
}
}

13
ErsatzTV.Core/Interfaces/Metadata/ILocalFileSystem.cs

@ -0,0 +1,13 @@ @@ -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<string> FindRelevantVideos(LocalMediaSource localMediaSource);
public bool ShouldRefreshMetadata(LocalMediaSource localMediaSource, MediaItem mediaItem);
public bool ShouldRefreshPoster(MediaItem mediaItem);
}
}

6
ErsatzTV.Core/Interfaces/Metadata/ILocalMediaScanner.cs

@ -1,11 +1,15 @@ @@ -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<Unit> ScanLocalMediaSource(LocalMediaSource localMediaSource, string ffprobePath);
Task<Unit> ScanLocalMediaSource(
LocalMediaSource localMediaSource,
string ffprobePath,
ScanningMode scanningMode);
}
}

2
ErsatzTV.Core/Interfaces/Metadata/ILocalStatisticsProvider.cs

@ -5,6 +5,6 @@ namespace ErsatzTV.Core.Interfaces.Metadata @@ -5,6 +5,6 @@ namespace ErsatzTV.Core.Interfaces.Metadata
{
public interface ILocalStatisticsProvider
{
Task RefreshStatistics(string ffprobePath, MediaItem mediaItem);
Task<bool> RefreshStatistics(string ffprobePath, MediaItem mediaItem);
}
}

2
ErsatzTV.Core/Interfaces/Metadata/ISmartCollectionBuilder.cs

@ -5,6 +5,6 @@ namespace ErsatzTV.Core.Interfaces.Metadata @@ -5,6 +5,6 @@ namespace ErsatzTV.Core.Interfaces.Metadata
{
public interface ISmartCollectionBuilder
{
Task RefreshSmartCollections(MediaItem mediaItem);
Task<bool> RefreshSmartCollections(MediaItem mediaItem);
}
}

2
ErsatzTV.Core/Interfaces/Repositories/IMediaCollectionRepository.cs

@ -20,7 +20,7 @@ namespace ErsatzTV.Core.Interfaces.Repositories @@ -20,7 +20,7 @@ namespace ErsatzTV.Core.Interfaces.Repositories
public Task<Option<List<MediaItem>>> GetSimpleMediaCollectionItems(int id);
public Task<Option<List<MediaItem>>> GetTelevisionMediaCollectionItems(int id);
public Task Update(SimpleMediaCollection collection);
public Task InsertOrIgnore(TelevisionMediaCollection collection);
public Task<bool> InsertOrIgnore(TelevisionMediaCollection collection);
public Task<Unit> ReplaceItems(int collectionId, List<MediaItem> mediaItems);
public Task Delete(int mediaCollectionId);
public Task DeleteEmptyTelevisionCollections();

2
ErsatzTV.Core/Interfaces/Repositories/IMediaItemRepository.cs

@ -15,7 +15,7 @@ namespace ErsatzTV.Core.Interfaces.Repositories @@ -15,7 +15,7 @@ namespace ErsatzTV.Core.Interfaces.Repositories
public Task<List<MediaItemSummary>> GetPageByType(MediaType mediaType, int pageNumber, int pageSize);
public Task<int> GetCountByType(MediaType mediaType);
public Task<List<MediaItem>> GetAllByMediaSourceId(int mediaSourceId);
public Task Update(MediaItem mediaItem);
public Task<bool> Update(MediaItem mediaItem);
public Task Delete(int mediaItemId);
}
}

64
ErsatzTV.Core/Metadata/LocalFileSystem.cs

@ -0,0 +1,64 @@ @@ -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<string> FindRelevantVideos(LocalMediaSource localMediaSource)
{
Seq<string> 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<string> excluded = allDirectories.Filter(ShouldExcludeDirectory);
Seq<string> 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<string> KnownExtensions = Seq(
".mpg", ".mp2", ".mpeg", ".mpe", ".mpv", ".ogg", ".mp4",
".m4p", ".m4v", ".avi", ".wmv", ".mov", ".mkv", ".ts");
private static readonly Seq<string> ExtraFolderNames = Seq(
"extras", "specials", "shorts", "scenes", "featurettes",
"behind the scenes", "deleted scenes", "interviews", "trailers");
// @formatter:on
}
}

148
ErsatzTV.Core/Metadata/LocalMediaScanner.cs

@ -1,5 +1,4 @@ @@ -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 @@ -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 @@ -32,7 +32,8 @@ namespace ErsatzTV.Core.Metadata
ILocalPosterProvider localPosterProvider,
ISmartCollectionBuilder smartCollectionBuilder,
IPlayoutBuilder playoutBuilder,
ILogger<LocalMediaScanner> logger)
ILogger<LocalMediaScanner> logger,
ILocalFileSystem localFileSystem)
{
_mediaItemRepository = mediaItemRepository;
_playoutRepository = playoutRepository;
@ -42,78 +43,45 @@ namespace ErsatzTV.Core.Metadata @@ -42,78 +43,45 @@ namespace ErsatzTV.Core.Metadata
_smartCollectionBuilder = smartCollectionBuilder;
_playoutBuilder = playoutBuilder;
_logger = logger;
_localFileSystem = localFileSystem;
}
public async Task<Unit> ScanLocalMediaSource(LocalMediaSource localMediaSource, string ffprobePath)
public async Task<Unit> 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<MediaItem> knownMediaItems = await _mediaItemRepository.GetAllByMediaSourceId(localMediaSource.Id);
var modifiedPlayoutIds = new List<int>();
// remove files that no longer exist
// add new files
// refresh metadata for any files where it is missing
var knownExtensions = new List<string>
{
".mpg", ".mp2", ".mpeg", ".mpe", ".mpv", ".ogg", ".mp4", ".m4p", ".m4v",
".avi", ".wmv", ".mov", ".mkv", ".ts"
};
Seq<string> 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<string> excluded = allDirectories.Filter(ShouldExcludeDirectory);
Seq<string> 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<string> allFiles = _localFileSystem.FindRelevantVideos(localMediaSource);
// check if the media item exists
(Seq<string> newFiles, Seq<MediaItem> 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<MediaItem> 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<MediaItem> staleMetadataMediaItems = scanningMode == ScanningMode.RescanAll
? existingMediaItems
: existingMediaItems.Filter(i => _localFileSystem.ShouldRefreshMetadata(localMediaSource, i));
Seq<MediaItem> 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<MediaItem>();
var addedMediaItems = new List<MediaItem>();
foreach (string path in newFiles)
{
_logger.LogDebug("Adding new media item {MediaItem}", path);
@ -129,7 +97,12 @@ namespace ErsatzTV.Core.Metadata @@ -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<MediaItem> stalePosterMediaItems = existingMediaItems
.Filter(_localFileSystem.ShouldRefreshPoster)
.Concat(addedMediaItems);
await RefreshPosterForItems(stalePosterMediaItems);
foreach (int playoutId in modifiedPlayoutIds.Distinct())
{
@ -143,17 +116,68 @@ namespace ErsatzTV.Core.Metadata @@ -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<Seq<MediaItem>> RefreshMetadataForItems(
string ffprobePath,
Seq<MediaItem> staleMetadataMediaItems)
{
await _localStatisticsProvider.RefreshStatistics(ffprobePath, mediaItem);
await _localMetadataProvider.RefreshMetadata(mediaItem);
await _localPosterProvider.RefreshPoster(mediaItem);
await _smartCollectionBuilder.RefreshSmartCollections(mediaItem);
var modifiedMediaItems = new List<MediaItem>();
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<MediaItem> stalePosterMediaItems)
{
(Seq<MediaItem> movies, Seq<MediaItem> 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<MediaItem> episodesToRefresh = episodes.GroupBy(e => e.Metadata.Title)
.SelectMany(g => (Option<MediaItem>) 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<MediaItem> 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<bool> 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;
}
}
}

11
ErsatzTV.Core/Metadata/LocalPosterProvider.cs

@ -1,4 +1,5 @@ @@ -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 @@ -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<LocalPosterProvider> _logger;
private readonly IMediaItemRepository _mediaItemRepository;
@ -46,8 +49,8 @@ namespace ErsatzTV.Core.Metadata @@ -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<string> possiblePaths = SupportedExtensions.Collect(
e => new[] { $"poster.{e}", Path.GetFileNameWithoutExtension(mediaItem.Path) + $"-poster.{e}" });
Option<string> maybePoster =
possiblePaths.Map(p => Path.Combine(folder, p)).FirstOrDefault(File.Exists);
return maybePoster;
@ -61,7 +64,7 @@ namespace ErsatzTV.Core.Metadata @@ -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<string> possiblePaths = SupportedExtensions.Collect(e => new[] { $"poster.{e}" });
Option<string> maybePoster =
possiblePaths.Map(p => Path.Combine(folder, p)).FirstOrDefault(File.Exists);
return maybePoster;

11
ErsatzTV.Core/Metadata/LocalStatisticsProvider.cs

@ -26,21 +26,22 @@ namespace ErsatzTV.Core.Metadata @@ -26,21 +26,22 @@ namespace ErsatzTV.Core.Metadata
_logger = logger;
}
public async Task RefreshStatistics(string ffprobePath, MediaItem mediaItem)
public async Task<bool> 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<bool> ApplyStatisticsUpdate(
MediaItem mediaItem,
MediaMetadata metadata)
{
@ -49,6 +50,8 @@ namespace ErsatzTV.Core.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 @@ -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<FFprobe> GetProbeOutput(string ffprobePath, MediaItem mediaItem)

8
ErsatzTV.Core/Metadata/ScanningMode.cs

@ -0,0 +1,8 @@ @@ -0,0 +1,8 @@
namespace ErsatzTV.Core.Metadata
{
public enum ScanningMode
{
Default = 0,
RescanAll = 1
}
}

8
ErsatzTV.Core/Metadata/SmartCollectionBuilder.cs

@ -16,12 +16,16 @@ namespace ErsatzTV.Core.Metadata @@ -16,12 +16,16 @@ namespace ErsatzTV.Core.Metadata
public SmartCollectionBuilder(IMediaCollectionRepository mediaCollectionRepository) =>
_mediaCollectionRepository = mediaCollectionRepository;
public async Task RefreshSmartCollections(MediaItem mediaItem)
public async Task<bool> RefreshSmartCollections(MediaItem mediaItem)
{
var results = new List<bool>();
foreach (TelevisionMediaCollection collection in GetTelevisionCollections(mediaItem))
{
await _mediaCollectionRepository.InsertOrIgnore(collection);
results.Add(await _mediaCollectionRepository.InsertOrIgnore(collection));
}
return results.Any(identity);
}
private IEnumerable<TelevisionMediaCollection> GetTelevisionCollections(MediaItem mediaItem)

7
ErsatzTV.Infrastructure/Data/Repositories/MediaCollectionRepository.cs

@ -85,15 +85,18 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -85,15 +85,18 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
return _dbContext.SaveChangesAsync();
}
public async Task InsertOrIgnore(TelevisionMediaCollection collection)
public async Task<bool> 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<Unit> ReplaceItems(int collectionId, List<MediaItem> mediaItems) =>

4
ErsatzTV.Infrastructure/Data/Repositories/MediaItemRepository.cs

@ -97,10 +97,10 @@ LIMIT {0} OFFSET {1}", @@ -97,10 +97,10 @@ LIMIT {0} OFFSET {1}",
.Filter(i => i.MediaSourceId == mediaSourceId)
.ToListAsync();
public async Task Update(MediaItem mediaItem)
public async Task<bool> Update(MediaItem mediaItem)
{
_dbContext.MediaItems.Update(mediaItem);
await _dbContext.SaveChangesAsync();
return await _dbContext.SaveChangesAsync() > 0;
}
public async Task Delete(int mediaItemId)

40
ErsatzTV.Infrastructure/Locking/EntityLocker.cs

@ -0,0 +1,40 @@ @@ -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<int, byte> _lockedMediaSources;
public EntityLocker() => _lockedMediaSources = new ConcurrentDictionary<int, byte>();
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);
}
}

1
ErsatzTV.sln.DotSettings

@ -13,6 +13,7 @@ @@ -13,6 +13,7 @@
<s:Boolean x:Key="/Default/UserDictionary/Words/=ersatztv/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=etvignore/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=faststart/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=featurettes/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=ffconcat/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=fflags/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=ffprobe/@EntryIndexedValue">True</s:Boolean>

7
ErsatzTV/Pages/LocalMediaSourceEditor.razor

@ -1,10 +1,12 @@ @@ -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<LocalMediaSourceEditor> Logger
@inject ISnackbar Snackbar
@inject IMediator Mediator
@inject IEntityLocker Locker
@inject ChannelWriter<IBackgroundServiceRequest> Channel
<div style="max-width: 400px;">
@ -84,8 +86,11 @@ @@ -84,8 +86,11 @@
},
Right: async vm =>
{
await Channel.WriteAsync(new ScanLocalMediaSource(vm.Id));
if (Locker.LockMediaSource(vm.Id))
{
await Channel.WriteAsync(new ScanLocalMediaSource(vm.Id, ScanningMode.Default));
NavigationManager.NavigateTo("/media/sources");
}
});
}
}

2
ErsatzTV/Pages/PlayoutEditor.razor

@ -65,7 +65,7 @@ @@ -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"));

14
ErsatzTV/Services/SchedulerService.cs

@ -6,6 +6,8 @@ using System.Threading.Tasks; @@ -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 @@ -16,15 +18,18 @@ namespace ErsatzTV.Services
public class SchedulerService : IHostedService
{
private readonly ChannelWriter<IBackgroundServiceRequest> _channel;
private readonly IEntityLocker _entityLocker;
private readonly IServiceScopeFactory _serviceScopeFactory;
private Timer _timer;
public SchedulerService(
IServiceScopeFactory serviceScopeFactory,
ChannelWriter<IBackgroundServiceRequest> channel)
ChannelWriter<IBackgroundServiceRequest> channel,
IEntityLocker entityLocker)
{
_serviceScopeFactory = serviceScopeFactory;
_channel = channel;
_entityLocker = entityLocker;
}
public Task StartAsync(CancellationToken cancellationToken)
@ -75,7 +80,12 @@ namespace ErsatzTV.Services @@ -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);
}
}
}
}

49
ErsatzTV/Shared/LocalMediaSources.razor

@ -1,16 +1,20 @@ @@ -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<IBackgroundServiceRequest> Channel
<MudTable Hover="true" Items="_mediaSources">
<MudTable Hover="true" Items="_mediaSources" Dense="true">
<ToolBarContent>
<MudText Typo="Typo.h6">Local Media Sources</MudText>
</ToolBarContent>
<ColGroup>
<col/>
<col style="width: 60px;"/>
<col style="width: 120px;"/>
</ColGroup>
<HeaderContent>
<MudTh>Folder</MudTh>
@ -19,7 +23,29 @@ @@ -19,7 +23,29 @@
<RowTemplate>
<MudTd DataLabel="Folder">@context.Folder</MudTd>
<MudTd>
<MudIconButton Icon="@Icons.Material.Filled.Delete" OnClick="@(_ => DeleteMediaSource(context))"></MudIconButton>
<div style="align-items: center; display: flex;">
@if (Locker.IsMediaSourceLocked(context.Id))
{
<div style="align-items: center; display: flex; height: 48px; justify-content: center; width: 48px;">
<MudProgressCircular Color="Color.Primary" Size="Size.Small" Indeterminate="true"/>
</div>
}
else
{
<MudTooltip Text="Refresh All Metadata">
<MudIconButton Icon="@Icons.Material.Filled.Refresh"
Disabled="@Locker.IsMediaSourceLocked(context.Id)"
OnClick="@(_ => RefreshAllMetadata(context))">
</MudIconButton>
</MudTooltip>
}
<MudTooltip Text="Delete Media Source">
<MudIconButton Icon="@Icons.Material.Filled.Delete"
Disabled="@Locker.IsMediaSourceLocked(context.Id)"
OnClick="@(_ => DeleteMediaSource(context))">
</MudIconButton>
</MudTooltip>
</div>
</MudTd>
</RowTemplate>
</MudTable>
@ -30,6 +56,9 @@ @@ -30,6 +56,9 @@
@code {
private IList<LocalMediaSourceViewModel> _mediaSources;
protected override void OnInitialized() =>
Locker.OnMediaSourceChanged += LockChanged;
protected override async Task OnParametersSetAsync() => await LoadMediaSources();
private async Task LoadMediaSources() =>
@ -57,4 +86,18 @@ @@ -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;
}

10
ErsatzTV/Startup.cs

@ -1,5 +1,6 @@ @@ -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; @@ -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; @@ -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 @@ -77,6 +80,11 @@ namespace ErsatzTV
services.AddMudServices();
Log.Logger.Information(
"ErsatzTV version {Version}",
Assembly.GetEntryAssembly().GetCustomAttribute<AssemblyInformationalVersionAttribute>()
?.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 @@ -149,6 +157,7 @@ namespace ErsatzTV
services.AddSingleton<IPlexSecretStore, PlexSecretStore>();
services.AddSingleton<IPlexTvApiClient, PlexTvApiClient>(); // TODO: does this need to be singleton?
services.AddSingleton<IPlexServerApiClient, PlexServerApiClient>();
services.AddSingleton<IEntityLocker, EntityLocker>();
AddChannel<IBackgroundServiceRequest>(services);
AddChannel<IPlexBackgroundServiceRequest>(services);
@ -170,6 +179,7 @@ namespace ErsatzTV @@ -170,6 +179,7 @@ namespace ErsatzTV
services.AddScoped<ILocalMediaScanner, LocalMediaScanner>();
services.AddScoped<IPlayoutBuilder, PlayoutBuilder>();
services.AddScoped<IImageCache, ImageCache>();
services.AddScoped<ILocalFileSystem, LocalFileSystem>();
services.AddHostedService<PlexService>();
services.AddHostedService<FFmpegLocatorService>();

1
ErsatzTV/_Imports.razor

@ -20,6 +20,7 @@ @@ -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
Loading…
Cancel
Save