rework television media (#26)
* rework television media * refactor poster saving * television and movie views are working again * remove dead code * use paper styling for all cards * add show poster, plot to seasons page * remove missing shows; cleanup interfaces * fix split show display (same show in different folders/sources) * add placeholder "add to schedule" button * no more duplicate television shows, even with the same show split across sources * stop releasing CLI for now * use season number as season placeholder * add television shows to collections * add television seasons to collections * add television episodes to collections * add movies to collections * remove movies, shows, seasons, episodes from collections * fix page width and menus * fix buffer size defaults * fix chronological episode ordering * allow deleting media collections * don't get stuck building a playout with an empty collection * schedule editing and playouts work again * minor cleanup * remove dead code * fix bugs with viewing movies as they are loading * add scanner tests; support nested movie folders * update collections docs * rearrange order of schedule items * add show and season to schedule * delete schedules that use legacy collections, reset all posters * move cleanup to new migration * load fallback metadata when nfo fails; don't require metadata in ui * update readme and screenshots
This commit is contained in:
@@ -1,4 +0,0 @@
|
||||
namespace ErsatzTV.Core.Metadata
|
||||
{
|
||||
public record ActionPlan(string TargetPath, ScanningAction TargetAction);
|
||||
}
|
||||
@@ -2,35 +2,58 @@
|
||||
using System.IO;
|
||||
using System.Text.RegularExpressions;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
|
||||
namespace ErsatzTV.Core.Metadata
|
||||
{
|
||||
public static class FallbackMetadataProvider
|
||||
public class FallbackMetadataProvider : IFallbackMetadataProvider
|
||||
{
|
||||
public static MediaMetadata GetFallbackMetadata(MediaItem mediaItem)
|
||||
public TelevisionShowMetadata GetFallbackMetadataForShow(string showFolder)
|
||||
{
|
||||
string fileName = Path.GetFileName(showFolder);
|
||||
var metadata = new TelevisionShowMetadata
|
||||
{ Source = MetadataSource.Fallback, Title = fileName ?? showFolder };
|
||||
return GetTelevisionShowMetadata(fileName, metadata);
|
||||
}
|
||||
|
||||
public static TelevisionEpisodeMetadata GetFallbackMetadata(TelevisionEpisodeMediaItem mediaItem)
|
||||
{
|
||||
string fileName = Path.GetFileName(mediaItem.Path);
|
||||
var metadata = new MediaMetadata { Source = MetadataSource.Fallback, Title = fileName ?? mediaItem.Path };
|
||||
var metadata = new TelevisionEpisodeMetadata
|
||||
{ Source = MetadataSource.Fallback, Title = fileName ?? mediaItem.Path };
|
||||
|
||||
if (fileName != null)
|
||||
{
|
||||
if (!(mediaItem.Source is LocalMediaSource localMediaSource))
|
||||
if (!(mediaItem.Source is LocalMediaSource))
|
||||
{
|
||||
return metadata;
|
||||
}
|
||||
|
||||
return localMediaSource.MediaType switch
|
||||
{
|
||||
MediaType.TvShow => GetTvShowMetadata(fileName, metadata),
|
||||
MediaType.Movie => GetMovieMetadata(fileName, metadata),
|
||||
_ => metadata
|
||||
};
|
||||
return GetEpisodeMetadata(fileName, metadata);
|
||||
}
|
||||
|
||||
return metadata;
|
||||
}
|
||||
|
||||
private static MediaMetadata GetTvShowMetadata(string fileName, MediaMetadata metadata)
|
||||
public static MovieMetadata GetFallbackMetadata(MovieMediaItem mediaItem)
|
||||
{
|
||||
string fileName = Path.GetFileName(mediaItem.Path);
|
||||
var metadata = new MovieMetadata { Source = MetadataSource.Fallback, Title = fileName ?? mediaItem.Path };
|
||||
|
||||
if (fileName != null)
|
||||
{
|
||||
if (!(mediaItem.Source is LocalMediaSource))
|
||||
{
|
||||
return metadata;
|
||||
}
|
||||
|
||||
return GetMovieMetadata(fileName, metadata);
|
||||
}
|
||||
|
||||
return metadata;
|
||||
}
|
||||
|
||||
private static TelevisionEpisodeMetadata GetEpisodeMetadata(string fileName, TelevisionEpisodeMetadata metadata)
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -38,10 +61,9 @@ namespace ErsatzTV.Core.Metadata
|
||||
Match match = Regex.Match(fileName, PATTERN);
|
||||
if (match.Success)
|
||||
{
|
||||
metadata.MediaType = MediaType.TvShow;
|
||||
metadata.Title = match.Groups[1].Value;
|
||||
metadata.SeasonNumber = int.Parse(match.Groups[2].Value);
|
||||
metadata.EpisodeNumber = int.Parse(match.Groups[3].Value);
|
||||
metadata.Season = int.Parse(match.Groups[2].Value);
|
||||
metadata.Episode = int.Parse(match.Groups[3].Value);
|
||||
}
|
||||
}
|
||||
catch (Exception)
|
||||
@@ -52,7 +74,7 @@ namespace ErsatzTV.Core.Metadata
|
||||
return metadata;
|
||||
}
|
||||
|
||||
private static MediaMetadata GetMovieMetadata(string fileName, MediaMetadata metadata)
|
||||
private static MovieMetadata GetMovieMetadata(string fileName, MovieMetadata metadata)
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -60,9 +82,30 @@ namespace ErsatzTV.Core.Metadata
|
||||
Match match = Regex.Match(fileName, PATTERN);
|
||||
if (match.Success)
|
||||
{
|
||||
metadata.MediaType = MediaType.Movie;
|
||||
metadata.Title = match.Groups[1].Value;
|
||||
metadata.Aired = new DateTime(int.Parse(match.Groups[2].Value), 1, 1);
|
||||
metadata.Year = int.Parse(match.Groups[2].Value);
|
||||
}
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// ignored
|
||||
}
|
||||
|
||||
return metadata;
|
||||
}
|
||||
|
||||
private static TelevisionShowMetadata GetTelevisionShowMetadata(
|
||||
string fileName,
|
||||
TelevisionShowMetadata metadata)
|
||||
{
|
||||
try
|
||||
{
|
||||
const string PATTERN = @"^(.*?)[\s.]+?[.\(](\d{4})[.\)].*$";
|
||||
Match match = Regex.Match(fileName, PATTERN);
|
||||
if (match.Success)
|
||||
{
|
||||
metadata.Title = match.Groups[1].Value;
|
||||
metadata.Year = int.Parse(match.Groups[2].Value);
|
||||
}
|
||||
}
|
||||
catch (Exception)
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using LanguageExt;
|
||||
using static LanguageExt.Prelude;
|
||||
|
||||
namespace ErsatzTV.Core.Metadata
|
||||
@@ -16,52 +16,13 @@ namespace ErsatzTV.Core.Metadata
|
||||
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);
|
||||
public IEnumerable<string> ListSubdirectories(string folder) =>
|
||||
Try(Directory.EnumerateDirectories(folder)).IfFail(new List<string>());
|
||||
|
||||
// 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));
|
||||
public IEnumerable<string> ListFiles(string folder) =>
|
||||
Try(Directory.EnumerateFiles(folder, "*", SearchOption.TopDirectoryOnly)).IfFail(new List<string>());
|
||||
|
||||
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
|
||||
public bool FileExists(string path) => File.Exists(path);
|
||||
public Task<byte[]> ReadAllBytes(string path) => File.ReadAllBytesAsync(path);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Images;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using LanguageExt;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ErsatzTV.Core.Metadata
|
||||
{
|
||||
public abstract class LocalFolderScanner
|
||||
{
|
||||
public static readonly List<string> VideoFileExtensions = new()
|
||||
{
|
||||
".mpg", ".mp2", ".mpeg", ".mpe", ".mpv", ".ogg", ".mp4",
|
||||
".m4p", ".m4v", ".avi", ".wmv", ".mov", ".mkv", ".ts"
|
||||
};
|
||||
|
||||
public static readonly List<string> ImageFileExtensions = new()
|
||||
{
|
||||
"jpg", "jpeg", "png", "gif", "tbn"
|
||||
};
|
||||
|
||||
public static readonly List<string> ExtraFiles = new()
|
||||
{
|
||||
"behindthescenes", "deleted", "featurette",
|
||||
"interview", "scene", "short", "trailer", "other"
|
||||
};
|
||||
|
||||
public static readonly List<string> ExtraDirectories = new List<string>
|
||||
{
|
||||
"behind the scenes", "deleted scenes", "featurettes",
|
||||
"interviews", "scenes", "shorts", "trailers", "other",
|
||||
"extras", "specials"
|
||||
}
|
||||
.Map(s => $"{Path.DirectorySeparatorChar}{s}{Path.DirectorySeparatorChar}")
|
||||
.ToList();
|
||||
|
||||
private readonly IImageCache _imageCache;
|
||||
|
||||
private readonly ILocalFileSystem _localFileSystem;
|
||||
private readonly ILocalStatisticsProvider _localStatisticsProvider;
|
||||
private readonly ILogger _logger;
|
||||
|
||||
protected LocalFolderScanner(
|
||||
ILocalFileSystem localFileSystem,
|
||||
ILocalStatisticsProvider localStatisticsProvider,
|
||||
IImageCache imageCache,
|
||||
ILogger logger)
|
||||
{
|
||||
_localFileSystem = localFileSystem;
|
||||
_localStatisticsProvider = localStatisticsProvider;
|
||||
_imageCache = imageCache;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
protected async Task<Either<BaseError, T>> UpdateStatistics<T>(T mediaItem, string ffprobePath)
|
||||
where T : MediaItem
|
||||
{
|
||||
try
|
||||
{
|
||||
if (mediaItem.Statistics is null ||
|
||||
(mediaItem.Statistics.LastWriteTime ?? DateTime.MinValue) <
|
||||
_localFileSystem.GetLastWriteTime(mediaItem.Path))
|
||||
{
|
||||
_logger.LogDebug("Refreshing {Attribute} for {Path}", "Statistics", mediaItem.Path);
|
||||
await _localStatisticsProvider.RefreshStatistics(ffprobePath, mediaItem);
|
||||
}
|
||||
|
||||
return mediaItem;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return BaseError.New(ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
protected async Task SavePosterToDisk<T>(
|
||||
T show,
|
||||
string posterPath,
|
||||
Func<T, Task<bool>> update,
|
||||
int height = 220) where T : IHasAPoster
|
||||
{
|
||||
byte[] originalBytes = await _localFileSystem.ReadAllBytes(posterPath);
|
||||
Either<BaseError, string> maybeHash = await _imageCache.ResizeAndSaveImage(originalBytes, height, null);
|
||||
await maybeHash.Match(
|
||||
hash =>
|
||||
{
|
||||
show.Poster = hash;
|
||||
show.PosterLastWriteTime = _localFileSystem.GetLastWriteTime(posterPath);
|
||||
return update(show);
|
||||
},
|
||||
error =>
|
||||
{
|
||||
_logger.LogWarning("Unable to save poster to disk from {Path}: {Error}", posterPath, error.Value);
|
||||
return Task.CompletedTask;
|
||||
});
|
||||
}
|
||||
|
||||
protected Task<Either<BaseError, string>> SavePosterToDisk(string posterPath, int height = 220) =>
|
||||
_localFileSystem.ReadAllBytes(posterPath)
|
||||
.Bind(bytes => _imageCache.ResizeAndSaveImage(bytes, height, null));
|
||||
}
|
||||
}
|
||||
@@ -1,289 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Images;
|
||||
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;
|
||||
using Seq = LanguageExt.Seq;
|
||||
|
||||
namespace ErsatzTV.Core.Metadata
|
||||
{
|
||||
public class LocalMediaScanner : ILocalMediaScanner
|
||||
{
|
||||
private readonly IImageCache _imageCache;
|
||||
private readonly ILocalFileSystem _localFileSystem;
|
||||
private readonly ILocalMediaSourcePlanner _localMediaSourcePlanner;
|
||||
private readonly ILocalMetadataProvider _localMetadataProvider;
|
||||
private readonly ILocalStatisticsProvider _localStatisticsProvider;
|
||||
private readonly ILogger<LocalMediaScanner> _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,
|
||||
ISmartCollectionBuilder smartCollectionBuilder,
|
||||
IPlayoutBuilder playoutBuilder,
|
||||
ILocalMediaSourcePlanner localMediaSourcePlanner,
|
||||
ILocalFileSystem localFileSystem,
|
||||
IImageCache imageCache,
|
||||
ILogger<LocalMediaScanner> logger)
|
||||
{
|
||||
_mediaItemRepository = mediaItemRepository;
|
||||
_playoutRepository = playoutRepository;
|
||||
_localStatisticsProvider = localStatisticsProvider;
|
||||
_localMetadataProvider = localMetadataProvider;
|
||||
_smartCollectionBuilder = smartCollectionBuilder;
|
||||
_playoutBuilder = playoutBuilder;
|
||||
_localMediaSourcePlanner = localMediaSourcePlanner;
|
||||
_localFileSystem = localFileSystem;
|
||||
_imageCache = imageCache;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<Unit> 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<MediaItem> knownMediaItems = await _mediaItemRepository.GetAllByMediaSourceId(localMediaSource.Id);
|
||||
var modifiedPlayoutIds = new List<int>();
|
||||
|
||||
Seq<LocalMediaSourcePlan> actions = _localMediaSourcePlanner.DetermineActions(
|
||||
localMediaSource.MediaType,
|
||||
knownMediaItems.ToSeq(),
|
||||
FindAllFiles(localMediaSource));
|
||||
|
||||
foreach (LocalMediaSourcePlan action in actions)
|
||||
{
|
||||
Option<ActionPlan> maybeAddPlan =
|
||||
action.ActionPlans.SingleOrDefault(plan => plan.TargetAction == ScanningAction.Add);
|
||||
await maybeAddPlan.IfSomeAsync(
|
||||
async plan =>
|
||||
{
|
||||
Option<MediaItem> maybeMediaItem = await AddMediaItem(localMediaSource, plan.TargetPath);
|
||||
|
||||
// any actions other than "add" need to operate on a media item
|
||||
maybeMediaItem.IfSome(mediaItem => action.Source = mediaItem);
|
||||
});
|
||||
|
||||
foreach (ActionPlan plan in action.ActionPlans.OrderBy(plan => (int) plan.TargetAction))
|
||||
{
|
||||
string sourcePath = action.Source.Match(
|
||||
mediaItem => mediaItem.Path,
|
||||
path => path);
|
||||
|
||||
_logger.LogDebug(
|
||||
"{Source}: {Action} with {File}",
|
||||
Path.GetFileName(sourcePath),
|
||||
plan.TargetAction,
|
||||
Path.GetRelativePath(Path.GetDirectoryName(sourcePath) ?? string.Empty, plan.TargetPath));
|
||||
|
||||
await action.Source.Match(
|
||||
async mediaItem =>
|
||||
{
|
||||
var changed = false;
|
||||
|
||||
switch (plan.TargetAction)
|
||||
{
|
||||
case ScanningAction.Remove:
|
||||
await RemoveMissingItem(mediaItem);
|
||||
break;
|
||||
case ScanningAction.Poster:
|
||||
await SavePosterForItem(mediaItem, plan.TargetPath);
|
||||
break;
|
||||
case ScanningAction.FallbackMetadata:
|
||||
await RefreshFallbackMetadataForItem(mediaItem);
|
||||
break;
|
||||
case ScanningAction.SidecarMetadata:
|
||||
await RefreshSidecarMetadataForItem(mediaItem, plan.TargetPath);
|
||||
break;
|
||||
case ScanningAction.Statistics:
|
||||
changed = await RefreshStatisticsForItem(mediaItem, ffprobePath);
|
||||
break;
|
||||
case ScanningAction.Collections:
|
||||
changed = await RefreshCollectionsForItem(mediaItem);
|
||||
break;
|
||||
}
|
||||
|
||||
if (changed)
|
||||
{
|
||||
List<int> ids =
|
||||
await _playoutRepository.GetPlayoutIdsForMediaItems(Seq.create(mediaItem));
|
||||
modifiedPlayoutIds.AddRange(ids);
|
||||
}
|
||||
},
|
||||
path =>
|
||||
{
|
||||
_logger.LogError("This is a bug, something went wrong processing {Path}", path);
|
||||
return Task.CompletedTask;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
foreach (int playoutId in modifiedPlayoutIds.Distinct())
|
||||
{
|
||||
Option<Playout> 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 Seq<string> FindAllFiles(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(path => File.Exists(Path.Combine(path, ".etvignore")));
|
||||
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))
|
||||
.OrderBy(identity)
|
||||
.ToSeq();
|
||||
}
|
||||
|
||||
private async Task<Option<MediaItem>> AddMediaItem(MediaSource mediaSource, string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
var mediaItem = new MediaItem
|
||||
{
|
||||
MediaSourceId = mediaSource.Id,
|
||||
Path = path,
|
||||
LastWriteTime = File.GetLastWriteTimeUtc(path)
|
||||
};
|
||||
|
||||
await _mediaItemRepository.Add(mediaItem);
|
||||
|
||||
return mediaItem;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to add media item for {Path}", path);
|
||||
return None;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RemoveMissingItem(MediaItem mediaItem)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _mediaItemRepository.Delete(mediaItem.Id);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to remove missing local media item {MediaItem}", mediaItem.Path);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SavePosterForItem(MediaItem mediaItem, string posterPath)
|
||||
{
|
||||
try
|
||||
{
|
||||
byte[] originalBytes = await File.ReadAllBytesAsync(posterPath);
|
||||
Either<BaseError, string> maybeHash = await _imageCache.ResizeAndSaveImage(originalBytes, 220, null);
|
||||
await maybeHash.Match(
|
||||
hash =>
|
||||
{
|
||||
mediaItem.Poster = hash;
|
||||
mediaItem.PosterLastWriteTime = File.GetLastWriteTimeUtc(posterPath);
|
||||
return _mediaItemRepository.Update(mediaItem);
|
||||
},
|
||||
error =>
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Unable to save poster to disk from {Path}: {Error}",
|
||||
posterPath,
|
||||
error.Value);
|
||||
return Task.CompletedTask;
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to refresh poster for media item {MediaItem}", mediaItem.Path);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<bool> RefreshStatisticsForItem(MediaItem mediaItem, string ffprobePath)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await _localStatisticsProvider.RefreshStatistics(ffprobePath, mediaItem);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to refresh statistics for media item {MediaItem}", mediaItem.Path);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<bool> RefreshCollectionsForItem(MediaItem mediaItem)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await _smartCollectionBuilder.RefreshSmartCollections(mediaItem);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to refresh collections for media item {MediaItem}", mediaItem.Path);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RefreshSidecarMetadataForItem(MediaItem mediaItem, string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _localMetadataProvider.RefreshSidecarMetadata(mediaItem, path);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to refresh nfo metadata for media item {MediaItem}", mediaItem.Path);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RefreshFallbackMetadataForItem(MediaItem mediaItem)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _localMetadataProvider.RefreshFallbackMetadata(mediaItem);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to refresh fallback metadata for media item {MediaItem}", mediaItem.Path);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
using System.Collections.Generic;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using LanguageExt;
|
||||
|
||||
namespace ErsatzTV.Core.Metadata
|
||||
{
|
||||
public record LocalMediaSourcePlan(Either<string, MediaItem> Source, List<ActionPlan> ActionPlans)
|
||||
{
|
||||
public Either<string, MediaItem> Source { get; set; } = Source;
|
||||
}
|
||||
}
|
||||
@@ -1,179 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
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
|
||||
{
|
||||
// TODO: this needs a better name
|
||||
public class LocalMediaSourcePlanner : ILocalMediaSourcePlanner
|
||||
{
|
||||
private static readonly Seq<string> ImageFileExtensions = Seq("jpg", "jpeg", "png", "gif", "tbn");
|
||||
private readonly ILocalFileSystem _localFileSystem;
|
||||
|
||||
public LocalMediaSourcePlanner(ILocalFileSystem localFileSystem) => _localFileSystem = localFileSystem;
|
||||
|
||||
public Seq<LocalMediaSourcePlan> DetermineActions(
|
||||
MediaType mediaType,
|
||||
Seq<MediaItem> mediaItems,
|
||||
Seq<string> files)
|
||||
{
|
||||
var results = new IntermediateResults();
|
||||
Seq<string> videoFiles = files.Filter(f => VideoFileExtensions.Contains(Path.GetExtension(f)))
|
||||
.Filter(f => !IsExtra(f));
|
||||
|
||||
(Seq<string> newFiles, Seq<MediaItem> existingMediaItems) = videoFiles.Map(
|
||||
s => mediaItems.Find(i => i.Path == s).ToEither(s))
|
||||
.Partition();
|
||||
|
||||
// new files
|
||||
foreach (string file in newFiles)
|
||||
{
|
||||
results.Add(file, new ActionPlan(file, ScanningAction.Add));
|
||||
results.Add(file, new ActionPlan(file, ScanningAction.Statistics));
|
||||
|
||||
Option<string> maybeNfoFile = LocateNfoFile(mediaType, files, file);
|
||||
maybeNfoFile.BiIter(
|
||||
nfoFile =>
|
||||
{
|
||||
results.Add(file, new ActionPlan(nfoFile, ScanningAction.SidecarMetadata));
|
||||
results.Add(file, new ActionPlan(nfoFile, ScanningAction.Collections));
|
||||
},
|
||||
() =>
|
||||
{
|
||||
results.Add(file, new ActionPlan(file, ScanningAction.FallbackMetadata));
|
||||
results.Add(file, new ActionPlan(file, ScanningAction.Collections));
|
||||
});
|
||||
|
||||
Option<string> maybePoster = LocatePoster(mediaType, files, file);
|
||||
maybePoster.IfSome(
|
||||
posterFile => results.Add(file, new ActionPlan(posterFile, ScanningAction.Poster)));
|
||||
}
|
||||
|
||||
// existing media items
|
||||
foreach (MediaItem mediaItem in existingMediaItems)
|
||||
{
|
||||
if ((mediaItem.LastWriteTime ?? DateTime.MinValue) < _localFileSystem.GetLastWriteTime(mediaItem.Path))
|
||||
{
|
||||
results.Add(mediaItem, new ActionPlan(mediaItem.Path, ScanningAction.Statistics));
|
||||
}
|
||||
|
||||
Option<string> maybeNfoFile = LocateNfoFile(mediaType, files, mediaItem.Path);
|
||||
maybeNfoFile.IfSome(
|
||||
nfoFile =>
|
||||
{
|
||||
if (mediaItem.Metadata == null || mediaItem.Metadata.Source == MetadataSource.Fallback ||
|
||||
(mediaItem.Metadata.LastWriteTime ?? DateTime.MinValue) <
|
||||
_localFileSystem.GetLastWriteTime(nfoFile))
|
||||
{
|
||||
results.Add(mediaItem, new ActionPlan(nfoFile, ScanningAction.SidecarMetadata));
|
||||
results.Add(mediaItem, new ActionPlan(nfoFile, ScanningAction.Collections));
|
||||
}
|
||||
});
|
||||
|
||||
Option<string> maybePoster = LocatePoster(mediaType, files, mediaItem.Path);
|
||||
maybePoster.IfSome(
|
||||
posterFile =>
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(mediaItem.Poster) ||
|
||||
(mediaItem.PosterLastWriteTime ?? DateTime.MinValue) <
|
||||
_localFileSystem.GetLastWriteTime(posterFile))
|
||||
{
|
||||
results.Add(mediaItem, new ActionPlan(posterFile, ScanningAction.Poster));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// missing media items
|
||||
foreach (MediaItem mediaItem in mediaItems.Where(i => !files.Contains(i.Path)))
|
||||
{
|
||||
results.Add(mediaItem, new ActionPlan(mediaItem.Path, ScanningAction.Remove));
|
||||
}
|
||||
|
||||
return results.Summarize();
|
||||
}
|
||||
|
||||
private static bool IsExtra(string path)
|
||||
{
|
||||
string folder = Path.GetFileName(Path.GetDirectoryName(path) ?? string.Empty);
|
||||
string file = Path.GetFileNameWithoutExtension(path);
|
||||
return ExtraDirectories.Contains(folder, StringComparer.OrdinalIgnoreCase)
|
||||
|| ExtraFiles.Any(f => file.EndsWith(f, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
private static Option<string> LocateNfoFile(MediaType mediaType, Seq<string> files, string file)
|
||||
{
|
||||
switch (mediaType)
|
||||
{
|
||||
case MediaType.Movie:
|
||||
string movieAsNfo = Path.ChangeExtension(file, "nfo");
|
||||
string movieNfo = Path.Combine(Path.GetDirectoryName(file) ?? string.Empty, "movie.nfo");
|
||||
return Seq(movieAsNfo, movieNfo)
|
||||
.Filter(s => files.Contains(s))
|
||||
.HeadOrNone();
|
||||
case MediaType.TvShow:
|
||||
string episodeAsNfo = Path.ChangeExtension(file, "nfo");
|
||||
return Optional(episodeAsNfo)
|
||||
.Filter(s => files.Contains(s))
|
||||
.HeadOrNone();
|
||||
}
|
||||
|
||||
return None;
|
||||
}
|
||||
|
||||
private static Option<string> LocatePoster(MediaType mediaType, Seq<string> files, string file)
|
||||
{
|
||||
string folder = Path.GetDirectoryName(file) ?? string.Empty;
|
||||
|
||||
switch (mediaType)
|
||||
{
|
||||
case MediaType.Movie:
|
||||
IEnumerable<string> possibleMoviePosters = ImageFileExtensions.Collect(
|
||||
ext => new[] { $"poster.{ext}", Path.GetFileNameWithoutExtension(file) + $"-poster.{ext}" })
|
||||
.Map(f => Path.Combine(folder, f));
|
||||
return possibleMoviePosters.Filter(p => files.Contains(p)).HeadOrNone();
|
||||
case MediaType.TvShow:
|
||||
string parentFolder = Directory.GetParent(folder)?.FullName ?? string.Empty;
|
||||
IEnumerable<string> possibleTvPosters = ImageFileExtensions
|
||||
.Collect(ext => new[] { $"poster.{ext}" })
|
||||
.Map(f => Path.Combine(parentFolder, f));
|
||||
return possibleTvPosters.Filter(p => files.Contains(p)).HeadOrNone();
|
||||
}
|
||||
|
||||
return None;
|
||||
}
|
||||
|
||||
private class IntermediateResults
|
||||
{
|
||||
private readonly List<Tuple<Either<string, MediaItem>, ActionPlan>> _rawResults = new();
|
||||
|
||||
public void Add(Either<string, MediaItem> source, ActionPlan plan) =>
|
||||
_rawResults.Add(Tuple(source, plan));
|
||||
|
||||
public Seq<LocalMediaSourcePlan> Summarize() =>
|
||||
_rawResults
|
||||
.GroupBy(t => t.Item1)
|
||||
.Select(g => new LocalMediaSourcePlan(g.Key, g.Select(g2 => g2.Item2).ToList()))
|
||||
.ToSeq();
|
||||
}
|
||||
|
||||
// @formatter:off
|
||||
private static readonly Seq<string> VideoFileExtensions = Seq(
|
||||
".mpg", ".mp2", ".mpeg", ".mpe", ".mpv", ".ogg", ".mp4",
|
||||
".m4p", ".m4v", ".avi", ".wmv", ".mov", ".mkv", ".ts");
|
||||
|
||||
private static readonly Seq<string> ExtraDirectories = Seq(
|
||||
"behind the scenes", "deleted scenes", "featurettes",
|
||||
"interviews", "scenes", "shorts", "trailers", "other",
|
||||
"extras", "specials");
|
||||
|
||||
private static readonly Seq<string> ExtraFiles = Seq(
|
||||
"behindthescenes", "deleted", "featurette",
|
||||
"interview", "scene", "short", "trailer", "other");
|
||||
// @formatter:on
|
||||
}
|
||||
}
|
||||
@@ -14,64 +14,117 @@ namespace ErsatzTV.Core.Metadata
|
||||
public class LocalMetadataProvider : ILocalMetadataProvider
|
||||
{
|
||||
private static readonly XmlSerializer MovieSerializer = new(typeof(MovieNfo));
|
||||
private static readonly XmlSerializer TvShowSerializer = new(typeof(TvShowEpisodeNfo));
|
||||
private static readonly XmlSerializer EpisodeSerializer = new(typeof(TvShowEpisodeNfo));
|
||||
private static readonly XmlSerializer TvShowSerializer = new(typeof(TvShowNfo));
|
||||
private readonly IFallbackMetadataProvider _fallbackMetadataProvider;
|
||||
private readonly ILocalFileSystem _localFileSystem;
|
||||
private readonly ILogger<LocalMetadataProvider> _logger;
|
||||
|
||||
private readonly IMediaItemRepository _mediaItemRepository;
|
||||
private readonly ITelevisionRepository _televisionRepository;
|
||||
|
||||
public LocalMetadataProvider(IMediaItemRepository mediaItemRepository, ILogger<LocalMetadataProvider> logger)
|
||||
public LocalMetadataProvider(
|
||||
IMediaItemRepository mediaItemRepository,
|
||||
ITelevisionRepository televisionRepository,
|
||||
IFallbackMetadataProvider fallbackMetadataProvider,
|
||||
ILocalFileSystem localFileSystem,
|
||||
ILogger<LocalMetadataProvider> logger)
|
||||
{
|
||||
_mediaItemRepository = mediaItemRepository;
|
||||
_televisionRepository = televisionRepository;
|
||||
_fallbackMetadataProvider = fallbackMetadataProvider;
|
||||
_localFileSystem = localFileSystem;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task RefreshSidecarMetadata(MediaItem mediaItem, string path)
|
||||
public Task<TelevisionShowMetadata> GetMetadataForShow(string showFolder)
|
||||
{
|
||||
Option<MediaMetadata> maybeMetadata = await LoadMetadata(mediaItem, path);
|
||||
await maybeMetadata.IfSomeAsync(metadata => ApplyMetadataUpdate(mediaItem, metadata));
|
||||
string nfoFileName = Path.Combine(showFolder, "tvshow.nfo");
|
||||
return Optional(_localFileSystem.FileExists(nfoFileName))
|
||||
.Filter(identity).AsTask()
|
||||
.Bind(_ => LoadTelevisionShowMetadata(nfoFileName))
|
||||
.IfNoneAsync(() => _fallbackMetadataProvider.GetFallbackMetadataForShow(showFolder).AsTask())
|
||||
.Map(
|
||||
m =>
|
||||
{
|
||||
m.SortTitle = GetSortTitle(m.Title);
|
||||
return m;
|
||||
});
|
||||
}
|
||||
|
||||
public Task RefreshFallbackMetadata(MediaItem mediaItem) =>
|
||||
ApplyMetadataUpdate(mediaItem, FallbackMetadataProvider.GetFallbackMetadata(mediaItem));
|
||||
|
||||
private async Task ApplyMetadataUpdate(MediaItem mediaItem, MediaMetadata metadata)
|
||||
{
|
||||
if (mediaItem.Metadata == null)
|
||||
public Task<Unit> RefreshSidecarMetadata(MediaItem mediaItem, string path) =>
|
||||
mediaItem switch
|
||||
{
|
||||
mediaItem.Metadata = new MediaMetadata();
|
||||
}
|
||||
TelevisionEpisodeMediaItem e => LoadMetadata(e, path)
|
||||
.Bind(maybeMetadata => maybeMetadata.IfSomeAsync(metadata => ApplyMetadataUpdate(e, metadata))),
|
||||
MovieMediaItem m => LoadMetadata(m, path)
|
||||
.Bind(maybeMetadata => maybeMetadata.IfSomeAsync(metadata => ApplyMetadataUpdate(m, metadata))),
|
||||
_ => Task.FromResult(Unit.Default)
|
||||
};
|
||||
|
||||
public Task<Unit> RefreshSidecarMetadata(TelevisionShow televisionShow, string showFolder) =>
|
||||
LoadMetadata(televisionShow, showFolder).Bind(
|
||||
maybeMetadata => maybeMetadata.IfSomeAsync(metadata => ApplyMetadataUpdate(televisionShow, metadata)));
|
||||
|
||||
public Task<Unit> RefreshFallbackMetadata(MediaItem mediaItem) =>
|
||||
mediaItem switch
|
||||
{
|
||||
TelevisionEpisodeMediaItem e => ApplyMetadataUpdate(e, FallbackMetadataProvider.GetFallbackMetadata(e))
|
||||
.ToUnit(),
|
||||
MovieMediaItem m => ApplyMetadataUpdate(m, FallbackMetadataProvider.GetFallbackMetadata(m)).ToUnit(),
|
||||
_ => Task.FromResult(Unit.Default)
|
||||
};
|
||||
|
||||
public Task<Unit> RefreshFallbackMetadata(TelevisionShow televisionShow, string showFolder) =>
|
||||
ApplyMetadataUpdate(televisionShow, _fallbackMetadataProvider.GetFallbackMetadataForShow(showFolder))
|
||||
.ToUnit();
|
||||
|
||||
private async Task ApplyMetadataUpdate(TelevisionEpisodeMediaItem mediaItem, TelevisionEpisodeMetadata metadata)
|
||||
{
|
||||
mediaItem.Metadata ??= new TelevisionEpisodeMetadata { TelevisionEpisodeId = mediaItem.Id };
|
||||
mediaItem.Metadata.Source = metadata.Source;
|
||||
mediaItem.Metadata.LastWriteTime = metadata.LastWriteTime;
|
||||
mediaItem.Metadata.MediaType = metadata.MediaType;
|
||||
mediaItem.Metadata.Title = metadata.Title;
|
||||
mediaItem.Metadata.Subtitle = metadata.Subtitle;
|
||||
mediaItem.Metadata.SortTitle = GetSortTitle(metadata.Title ?? string.Empty);
|
||||
mediaItem.Metadata.Description = metadata.Description;
|
||||
mediaItem.Metadata.EpisodeNumber = metadata.EpisodeNumber;
|
||||
mediaItem.Metadata.SeasonNumber = metadata.SeasonNumber;
|
||||
mediaItem.Metadata.SortTitle = GetSortTitle(metadata.Title);
|
||||
mediaItem.Metadata.Season = metadata.Season;
|
||||
mediaItem.Metadata.Episode = metadata.Episode;
|
||||
mediaItem.Metadata.Plot = metadata.Plot;
|
||||
mediaItem.Metadata.Aired = metadata.Aired;
|
||||
|
||||
await _televisionRepository.Update(mediaItem);
|
||||
}
|
||||
|
||||
private async Task ApplyMetadataUpdate(MovieMediaItem mediaItem, MovieMetadata metadata)
|
||||
{
|
||||
mediaItem.Metadata ??= new MovieMetadata();
|
||||
mediaItem.Metadata.Source = metadata.Source;
|
||||
mediaItem.Metadata.LastWriteTime = metadata.LastWriteTime;
|
||||
mediaItem.Metadata.Title = metadata.Title;
|
||||
mediaItem.Metadata.SortTitle = GetSortTitle(metadata.Title);
|
||||
mediaItem.Metadata.Year = metadata.Year;
|
||||
mediaItem.Metadata.Premiered = metadata.Premiered;
|
||||
mediaItem.Metadata.Plot = metadata.Plot;
|
||||
mediaItem.Metadata.Outline = metadata.Outline;
|
||||
mediaItem.Metadata.Tagline = metadata.Tagline;
|
||||
mediaItem.Metadata.ContentRating = metadata.ContentRating;
|
||||
|
||||
await _mediaItemRepository.Update(mediaItem);
|
||||
}
|
||||
|
||||
private static string GetSortTitle(string title)
|
||||
private async Task ApplyMetadataUpdate(TelevisionShow televisionShow, TelevisionShowMetadata metadata)
|
||||
{
|
||||
if (title.StartsWith("the ", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return title.Substring(4);
|
||||
}
|
||||
televisionShow.Metadata ??= new TelevisionShowMetadata();
|
||||
televisionShow.Metadata.Source = metadata.Source;
|
||||
televisionShow.Metadata.LastWriteTime = metadata.LastWriteTime;
|
||||
televisionShow.Metadata.Title = metadata.Title;
|
||||
televisionShow.Metadata.Plot = metadata.Plot;
|
||||
televisionShow.Metadata.Year = metadata.Year;
|
||||
televisionShow.Metadata.SortTitle = GetSortTitle(metadata.Title);
|
||||
|
||||
if (title.StartsWith("Æ"))
|
||||
{
|
||||
return title.Replace("Æ", "E");
|
||||
}
|
||||
|
||||
return title;
|
||||
await _televisionRepository.Update(televisionShow);
|
||||
}
|
||||
|
||||
private async Task<Option<MediaMetadata>> LoadMetadata(MediaItem mediaItem, string nfoFileName)
|
||||
private async Task<Option<MovieMetadata>> LoadMetadata(MovieMediaItem mediaItem, string nfoFileName)
|
||||
{
|
||||
if (nfoFileName == null || !File.Exists(nfoFileName))
|
||||
{
|
||||
@@ -79,71 +132,122 @@ namespace ErsatzTV.Core.Metadata
|
||||
return None;
|
||||
}
|
||||
|
||||
if (!(mediaItem.Source is LocalMediaSource localMediaSource))
|
||||
if (!(mediaItem.Source is LocalMediaSource))
|
||||
{
|
||||
_logger.LogDebug("Media source {Name} is not a local media source", mediaItem.Source.Name);
|
||||
return None;
|
||||
}
|
||||
|
||||
return localMediaSource.MediaType switch
|
||||
{
|
||||
MediaType.Movie => await LoadMovieMetadata(nfoFileName),
|
||||
MediaType.TvShow => await LoadTvShowMetadata(nfoFileName),
|
||||
_ => None
|
||||
};
|
||||
return await LoadMovieMetadata(mediaItem, nfoFileName);
|
||||
}
|
||||
|
||||
private async Task<Option<MediaMetadata>> LoadTvShowMetadata(string nfoFileName)
|
||||
private async Task<Option<TelevisionEpisodeMetadata>> LoadMetadata(
|
||||
TelevisionEpisodeMediaItem mediaItem,
|
||||
string nfoFileName)
|
||||
{
|
||||
if (nfoFileName == null || !File.Exists(nfoFileName))
|
||||
{
|
||||
_logger.LogDebug("NFO file does not exist at {Path}", nfoFileName);
|
||||
return None;
|
||||
}
|
||||
|
||||
if (!(mediaItem.Source is LocalMediaSource))
|
||||
{
|
||||
_logger.LogDebug("Media source {Name} is not a local media source", mediaItem.Source.Name);
|
||||
return None;
|
||||
}
|
||||
|
||||
return await LoadEpisodeMetadata(mediaItem, nfoFileName);
|
||||
}
|
||||
|
||||
private async Task<Option<TelevisionShowMetadata>> LoadMetadata(
|
||||
TelevisionShow televisionShow,
|
||||
string nfoFileName)
|
||||
{
|
||||
if (nfoFileName == null || !File.Exists(nfoFileName))
|
||||
{
|
||||
_logger.LogDebug("NFO file does not exist at {Path}", nfoFileName);
|
||||
return None;
|
||||
}
|
||||
|
||||
return await LoadTelevisionShowMetadata(nfoFileName);
|
||||
}
|
||||
|
||||
private async Task<Option<TelevisionShowMetadata>> LoadTelevisionShowMetadata(string nfoFileName)
|
||||
{
|
||||
try
|
||||
{
|
||||
await using FileStream fileStream = File.Open(nfoFileName, FileMode.Open, FileAccess.Read);
|
||||
Option<TvShowEpisodeNfo> maybeNfo = TvShowSerializer.Deserialize(fileStream) as TvShowEpisodeNfo;
|
||||
return maybeNfo.Match<Option<MediaMetadata>>(
|
||||
nfo => new MediaMetadata
|
||||
Option<TvShowNfo> maybeNfo = TvShowSerializer.Deserialize(fileStream) as TvShowNfo;
|
||||
return maybeNfo.Match<Option<TelevisionShowMetadata>>(
|
||||
nfo => new TelevisionShowMetadata
|
||||
{
|
||||
Source = MetadataSource.Sidecar,
|
||||
LastWriteTime = File.GetLastWriteTimeUtc(nfoFileName),
|
||||
MediaType = MediaType.TvShow,
|
||||
Title = nfo.ShowTitle,
|
||||
Subtitle = nfo.Title,
|
||||
Description = nfo.Outline,
|
||||
EpisodeNumber = nfo.Episode,
|
||||
SeasonNumber = nfo.Season,
|
||||
Aired = GetAired(nfo.Aired)
|
||||
Title = nfo.Title,
|
||||
Plot = nfo.Plot,
|
||||
Year = nfo.Year
|
||||
},
|
||||
None);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "Failed to read TV nfo metadata from {Path}", nfoFileName);
|
||||
_logger.LogDebug(ex, "Failed to read TV show nfo metadata from {Path}", nfoFileName);
|
||||
return None;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<Option<MediaMetadata>> LoadMovieMetadata(string nfoFileName)
|
||||
private async Task<Option<TelevisionEpisodeMetadata>> LoadEpisodeMetadata(TelevisionEpisodeMediaItem mediaItem, string nfoFileName)
|
||||
{
|
||||
try
|
||||
{
|
||||
await using FileStream fileStream = File.Open(nfoFileName, FileMode.Open, FileAccess.Read);
|
||||
Option<TvShowEpisodeNfo> maybeNfo = EpisodeSerializer.Deserialize(fileStream) as TvShowEpisodeNfo;
|
||||
return maybeNfo.Match<Option<TelevisionEpisodeMetadata>>(
|
||||
nfo => new TelevisionEpisodeMetadata
|
||||
{
|
||||
Source = MetadataSource.Sidecar,
|
||||
LastWriteTime = File.GetLastWriteTimeUtc(nfoFileName),
|
||||
Title = nfo.Title,
|
||||
Aired = GetAired(nfo.Aired),
|
||||
Episode = nfo.Episode,
|
||||
Season = nfo.Season,
|
||||
Plot = nfo.Plot
|
||||
},
|
||||
None);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "Failed to read TV episode nfo metadata from {Path}", nfoFileName);
|
||||
return FallbackMetadataProvider.GetFallbackMetadata(mediaItem);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<Option<MovieMetadata>> LoadMovieMetadata(MovieMediaItem mediaItem, string nfoFileName)
|
||||
{
|
||||
try
|
||||
{
|
||||
await using FileStream fileStream = File.Open(nfoFileName, FileMode.Open, FileAccess.Read);
|
||||
Option<MovieNfo> maybeNfo = MovieSerializer.Deserialize(fileStream) as MovieNfo;
|
||||
return maybeNfo.Match<Option<MediaMetadata>>(
|
||||
nfo => new MediaMetadata
|
||||
return maybeNfo.Match<Option<MovieMetadata>>(
|
||||
nfo => new MovieMetadata
|
||||
{
|
||||
Source = MetadataSource.Sidecar,
|
||||
LastWriteTime = File.GetLastWriteTimeUtc(nfoFileName),
|
||||
MediaType = MediaType.Movie,
|
||||
Title = nfo.Title,
|
||||
Description = nfo.Outline,
|
||||
ContentRating = nfo.ContentRating,
|
||||
Aired = GetAired(nfo.Premiered)
|
||||
Year = nfo.Year,
|
||||
Premiered = nfo.Premiered,
|
||||
Plot = nfo.Plot,
|
||||
Outline = nfo.Outline,
|
||||
Tagline = nfo.Tagline,
|
||||
ContentRating = nfo.ContentRating
|
||||
},
|
||||
None);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "Failed to read Movie nfo metadata from {Path}", nfoFileName);
|
||||
return None;
|
||||
return FallbackMetadataProvider.GetFallbackMetadata(mediaItem);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -162,6 +266,26 @@ namespace ErsatzTV.Core.Metadata
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string GetSortTitle(string title)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(title))
|
||||
{
|
||||
return title;
|
||||
}
|
||||
|
||||
if (title.StartsWith("the ", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return title.Substring(4);
|
||||
}
|
||||
|
||||
if (title.StartsWith("Æ"))
|
||||
{
|
||||
return title.Replace("Æ", "E");
|
||||
}
|
||||
|
||||
return title;
|
||||
}
|
||||
|
||||
[XmlRoot("movie")]
|
||||
public class MovieNfo
|
||||
{
|
||||
@@ -171,11 +295,33 @@ namespace ErsatzTV.Core.Metadata
|
||||
[XmlElement("outline")]
|
||||
public string Outline { get; set; }
|
||||
|
||||
[XmlElement("year")]
|
||||
public int Year { get; set; }
|
||||
|
||||
[XmlElement("mpaa")]
|
||||
public string ContentRating { get; set; }
|
||||
|
||||
[XmlElement("premiered")]
|
||||
public string Premiered { get; set; }
|
||||
public DateTime Premiered { get; set; }
|
||||
|
||||
[XmlElement("plot")]
|
||||
public string Plot { get; set; }
|
||||
|
||||
[XmlElement("tagline")]
|
||||
public string Tagline { get; set; }
|
||||
}
|
||||
|
||||
[XmlRoot("tvshow")]
|
||||
public class TvShowNfo
|
||||
{
|
||||
[XmlElement("title")]
|
||||
public string Title { get; set; }
|
||||
|
||||
[XmlElement("year")]
|
||||
public int Year { get; set; }
|
||||
|
||||
[XmlElement("plot")]
|
||||
public string Plot { get; set; }
|
||||
}
|
||||
|
||||
[XmlRoot("episodedetails")]
|
||||
@@ -187,9 +333,6 @@ namespace ErsatzTV.Core.Metadata
|
||||
[XmlElement("title")]
|
||||
public string Title { get; set; }
|
||||
|
||||
[XmlElement("outline")]
|
||||
public string Outline { get; set; }
|
||||
|
||||
[XmlElement("episode")]
|
||||
public int Episode { get; set; }
|
||||
|
||||
@@ -201,6 +344,9 @@ namespace ErsatzTV.Core.Metadata
|
||||
|
||||
[XmlElement("aired")]
|
||||
public string Aired { get; set; }
|
||||
|
||||
[XmlElement("plot")]
|
||||
public string Plot { get; set; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,93 +0,0 @@
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Images;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using LanguageExt;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using static LanguageExt.Prelude;
|
||||
|
||||
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;
|
||||
|
||||
public LocalPosterProvider(
|
||||
IMediaItemRepository mediaItemRepository,
|
||||
IImageCache imageCache,
|
||||
ILogger<LocalPosterProvider> logger)
|
||||
{
|
||||
_mediaItemRepository = mediaItemRepository;
|
||||
_imageCache = imageCache;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task RefreshPoster(MediaItem mediaItem)
|
||||
{
|
||||
Option<string> maybePosterPath = mediaItem.Metadata.MediaType switch
|
||||
{
|
||||
MediaType.Movie => RefreshMoviePoster(mediaItem),
|
||||
MediaType.TvShow => RefreshTelevisionPoster(mediaItem),
|
||||
_ => None
|
||||
};
|
||||
|
||||
await maybePosterPath.Match(
|
||||
path => SavePosterToDisk(mediaItem, path),
|
||||
Task.CompletedTask);
|
||||
}
|
||||
|
||||
private static Option<string> RefreshMoviePoster(MediaItem mediaItem)
|
||||
{
|
||||
string folder = Path.GetDirectoryName(mediaItem.Path);
|
||||
if (folder != null)
|
||||
{
|
||||
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;
|
||||
}
|
||||
|
||||
return None;
|
||||
}
|
||||
|
||||
private Option<string> RefreshTelevisionPoster(MediaItem mediaItem)
|
||||
{
|
||||
string folder = Directory.GetParent(Path.GetDirectoryName(mediaItem.Path) ?? string.Empty)?.FullName;
|
||||
if (folder != null)
|
||||
{
|
||||
IEnumerable<string> possiblePaths = SupportedExtensions.Collect(e => new[] { $"poster.{e}" });
|
||||
Option<string> maybePoster =
|
||||
possiblePaths.Map(p => Path.Combine(folder, p)).FirstOrDefault(File.Exists);
|
||||
return maybePoster;
|
||||
}
|
||||
|
||||
return None;
|
||||
}
|
||||
|
||||
public async Task SavePosterToDisk(MediaItem mediaItem, string posterPath)
|
||||
{
|
||||
byte[] originalBytes = await File.ReadAllBytesAsync(posterPath);
|
||||
Either<BaseError, string> maybeHash = await _imageCache.ResizeAndSaveImage(originalBytes, 220, null);
|
||||
await maybeHash.Match(
|
||||
hash =>
|
||||
{
|
||||
mediaItem.Poster = hash;
|
||||
return _mediaItemRepository.Update(mediaItem);
|
||||
},
|
||||
error =>
|
||||
{
|
||||
_logger.LogWarning("Unable to save poster to disk from {Path}: {Error}", posterPath, error.Value);
|
||||
return Task.CompletedTask;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -15,14 +15,17 @@ namespace ErsatzTV.Core.Metadata
|
||||
{
|
||||
public class LocalStatisticsProvider : ILocalStatisticsProvider
|
||||
{
|
||||
private readonly ILocalFileSystem _localFileSystem;
|
||||
private readonly ILogger<LocalStatisticsProvider> _logger;
|
||||
private readonly IMediaItemRepository _mediaItemRepository;
|
||||
|
||||
public LocalStatisticsProvider(
|
||||
IMediaItemRepository mediaItemRepository,
|
||||
ILocalFileSystem localFileSystem,
|
||||
ILogger<LocalStatisticsProvider> logger)
|
||||
{
|
||||
_mediaItemRepository = mediaItemRepository;
|
||||
_localFileSystem = localFileSystem;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
@@ -31,8 +34,8 @@ namespace ErsatzTV.Core.Metadata
|
||||
try
|
||||
{
|
||||
FFprobe ffprobe = await GetProbeOutput(ffprobePath, mediaItem);
|
||||
MediaMetadata metadata = ProjectToMediaMetadata(ffprobe);
|
||||
return await ApplyStatisticsUpdate(mediaItem, metadata);
|
||||
MediaItemStatistics statistics = ProjectToMediaItemStatistics(ffprobe);
|
||||
return await ApplyStatisticsUpdate(mediaItem, statistics);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -43,23 +46,24 @@ namespace ErsatzTV.Core.Metadata
|
||||
|
||||
private async Task<bool> ApplyStatisticsUpdate(
|
||||
MediaItem mediaItem,
|
||||
MediaMetadata metadata)
|
||||
MediaItemStatistics statistics)
|
||||
{
|
||||
if (mediaItem.Metadata == null)
|
||||
if (mediaItem.Statistics == null)
|
||||
{
|
||||
mediaItem.Metadata = new MediaMetadata();
|
||||
mediaItem.Statistics = new MediaItemStatistics();
|
||||
}
|
||||
|
||||
bool durationChange = mediaItem.Metadata.Duration != metadata.Duration;
|
||||
bool durationChange = mediaItem.Statistics.Duration != statistics.Duration;
|
||||
|
||||
mediaItem.Metadata.Duration = metadata.Duration;
|
||||
mediaItem.Metadata.AudioCodec = metadata.AudioCodec;
|
||||
mediaItem.Metadata.SampleAspectRatio = metadata.SampleAspectRatio;
|
||||
mediaItem.Metadata.DisplayAspectRatio = metadata.DisplayAspectRatio;
|
||||
mediaItem.Metadata.Width = metadata.Width;
|
||||
mediaItem.Metadata.Height = metadata.Height;
|
||||
mediaItem.Metadata.VideoCodec = metadata.VideoCodec;
|
||||
mediaItem.Metadata.VideoScanType = metadata.VideoScanType;
|
||||
mediaItem.Statistics.LastWriteTime = _localFileSystem.GetLastWriteTime(mediaItem.Path);
|
||||
mediaItem.Statistics.Duration = statistics.Duration;
|
||||
mediaItem.Statistics.AudioCodec = statistics.AudioCodec;
|
||||
mediaItem.Statistics.SampleAspectRatio = statistics.SampleAspectRatio;
|
||||
mediaItem.Statistics.DisplayAspectRatio = statistics.DisplayAspectRatio;
|
||||
mediaItem.Statistics.Width = statistics.Width;
|
||||
mediaItem.Statistics.Height = statistics.Height;
|
||||
mediaItem.Statistics.VideoCodec = statistics.VideoCodec;
|
||||
mediaItem.Statistics.VideoScanType = statistics.VideoScanType;
|
||||
|
||||
return await _mediaItemRepository.Update(mediaItem) && durationChange;
|
||||
}
|
||||
@@ -97,7 +101,7 @@ namespace ErsatzTV.Core.Metadata
|
||||
});
|
||||
}
|
||||
|
||||
private MediaMetadata ProjectToMediaMetadata(FFprobe probeOutput) =>
|
||||
private MediaItemStatistics ProjectToMediaItemStatistics(FFprobe probeOutput) =>
|
||||
Optional(probeOutput)
|
||||
.Filter(json => json?.format != null && json.streams != null)
|
||||
.ToValidation<BaseError>("Unable to parse ffprobe output")
|
||||
@@ -107,12 +111,12 @@ namespace ErsatzTV.Core.Metadata
|
||||
{
|
||||
var duration = TimeSpan.FromSeconds(double.Parse(json.format.duration));
|
||||
|
||||
var metadata = new MediaMetadata { Duration = duration };
|
||||
var statistics = new MediaItemStatistics { Duration = duration };
|
||||
|
||||
FFprobeStream audioStream = json.streams.FirstOrDefault(s => s.codec_type == "audio");
|
||||
if (audioStream != null)
|
||||
{
|
||||
metadata = metadata with
|
||||
statistics = statistics with
|
||||
{
|
||||
AudioCodec = audioStream.codec_name
|
||||
};
|
||||
@@ -121,7 +125,7 @@ namespace ErsatzTV.Core.Metadata
|
||||
FFprobeStream videoStream = json.streams.FirstOrDefault(s => s.codec_type == "video");
|
||||
if (videoStream != null)
|
||||
{
|
||||
metadata = metadata with
|
||||
statistics = statistics with
|
||||
{
|
||||
SampleAspectRatio = videoStream.sample_aspect_ratio,
|
||||
DisplayAspectRatio = videoStream.display_aspect_ratio,
|
||||
@@ -132,9 +136,9 @@ namespace ErsatzTV.Core.Metadata
|
||||
};
|
||||
}
|
||||
|
||||
return metadata;
|
||||
return statistics;
|
||||
},
|
||||
_ => new MediaMetadata());
|
||||
_ => new MediaItemStatistics());
|
||||
|
||||
private VideoScanType ScanTypeFromFieldOrder(string fieldOrder) =>
|
||||
fieldOrder?.ToLowerInvariant() switch
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Core.Interfaces.Images;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using LanguageExt;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using static LanguageExt.Prelude;
|
||||
using Seq = LanguageExt.Seq;
|
||||
|
||||
namespace ErsatzTV.Core.Metadata
|
||||
{
|
||||
public class MovieFolderScanner : LocalFolderScanner, IMovieFolderScanner
|
||||
{
|
||||
private readonly ILocalFileSystem _localFileSystem;
|
||||
private readonly ILocalMetadataProvider _localMetadataProvider;
|
||||
private readonly ILogger<MovieFolderScanner> _logger;
|
||||
private readonly IMovieRepository _movieRepository;
|
||||
|
||||
public MovieFolderScanner(
|
||||
ILocalFileSystem localFileSystem,
|
||||
IMovieRepository movieRepository,
|
||||
ILocalStatisticsProvider localStatisticsProvider,
|
||||
ILocalMetadataProvider localMetadataProvider,
|
||||
IImageCache imageCache,
|
||||
ILogger<MovieFolderScanner> logger)
|
||||
: base(localFileSystem, localStatisticsProvider, imageCache, logger)
|
||||
{
|
||||
_localFileSystem = localFileSystem;
|
||||
_movieRepository = movieRepository;
|
||||
_localMetadataProvider = localMetadataProvider;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<Either<BaseError, Unit>> ScanFolder(LocalMediaSource localMediaSource, string ffprobePath)
|
||||
{
|
||||
if (!_localFileSystem.IsMediaSourceAccessible(localMediaSource))
|
||||
{
|
||||
return new MediaSourceInaccessible();
|
||||
}
|
||||
|
||||
var folderQueue = new Queue<string>();
|
||||
foreach (string folder in _localFileSystem.ListSubdirectories(localMediaSource.Folder).OrderBy(identity))
|
||||
{
|
||||
folderQueue.Enqueue(folder);
|
||||
}
|
||||
|
||||
while (folderQueue.Count > 0)
|
||||
{
|
||||
string movieFolder = folderQueue.Dequeue();
|
||||
|
||||
var allFiles = _localFileSystem.ListFiles(movieFolder)
|
||||
.Filter(f => VideoFileExtensions.Contains(Path.GetExtension(f)))
|
||||
.Filter(
|
||||
f => !ExtraFiles.Any(
|
||||
e => Path.GetFileNameWithoutExtension(f).EndsWith(e, StringComparison.OrdinalIgnoreCase)))
|
||||
.ToList();
|
||||
|
||||
if (allFiles.Count == 0)
|
||||
{
|
||||
foreach (string subdirectory in _localFileSystem.ListSubdirectories(movieFolder).OrderBy(identity))
|
||||
{
|
||||
folderQueue.Enqueue(subdirectory);
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (string file in allFiles.OrderBy(identity))
|
||||
{
|
||||
// TODO: figure out how to rebuild playlists
|
||||
Either<BaseError, MovieMediaItem> x = await _movieRepository.GetOrAdd(localMediaSource.Id, file);
|
||||
|
||||
Either<BaseError, MovieMediaItem> maybeMovie = await x.AsTask()
|
||||
.BindT(movie => UpdateStatistics(movie, ffprobePath).MapT(_ => movie))
|
||||
.BindT(UpdateMetadata)
|
||||
.BindT(UpdatePoster);
|
||||
|
||||
maybeMovie.IfLeft(
|
||||
error => _logger.LogWarning("Error processing movie at {Path}: {Error}", file, error.Value));
|
||||
}
|
||||
}
|
||||
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
private async Task<Either<BaseError, MovieMediaItem>> UpdateMetadata(MovieMediaItem movie)
|
||||
{
|
||||
try
|
||||
{
|
||||
await LocateNfoFile(movie).Match(
|
||||
async nfoFile =>
|
||||
{
|
||||
if (movie.Metadata == null || movie.Metadata.Source == MetadataSource.Fallback ||
|
||||
(movie.Metadata.LastWriteTime ?? DateTime.MinValue) <
|
||||
_localFileSystem.GetLastWriteTime(nfoFile))
|
||||
{
|
||||
_logger.LogDebug("Refreshing {Attribute} from {Path}", "Sidecar Metadata", nfoFile);
|
||||
await _localMetadataProvider.RefreshSidecarMetadata(movie, nfoFile);
|
||||
}
|
||||
},
|
||||
async () =>
|
||||
{
|
||||
if (movie.Metadata == null)
|
||||
{
|
||||
_logger.LogDebug("Refreshing {Attribute} for {Path}", "Fallback Metadata", movie.Path);
|
||||
await _localMetadataProvider.RefreshFallbackMetadata(movie);
|
||||
}
|
||||
});
|
||||
|
||||
return movie;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return BaseError.New(ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<Either<BaseError, MovieMediaItem>> UpdatePoster(MovieMediaItem movie)
|
||||
{
|
||||
try
|
||||
{
|
||||
await LocatePoster(movie).IfSomeAsync(
|
||||
async posterFile =>
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(movie.Poster) ||
|
||||
(movie.PosterLastWriteTime ?? DateTime.MinValue) <
|
||||
_localFileSystem.GetLastWriteTime(posterFile))
|
||||
{
|
||||
_logger.LogDebug("Refreshing {Attribute} from {Path}", "Poster", posterFile);
|
||||
await SavePosterToDisk(movie, posterFile, _movieRepository.Update, 440);
|
||||
}
|
||||
});
|
||||
|
||||
return movie;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return BaseError.New(ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private Option<string> LocateNfoFile(MovieMediaItem movie)
|
||||
{
|
||||
string movieAsNfo = Path.ChangeExtension(movie.Path, "nfo");
|
||||
string movieNfo = Path.Combine(Path.GetDirectoryName(movie.Path) ?? string.Empty, "movie.nfo");
|
||||
return Seq.create(movieAsNfo, movieNfo)
|
||||
.Filter(s => _localFileSystem.FileExists(s))
|
||||
.HeadOrNone();
|
||||
}
|
||||
|
||||
private Option<string> LocatePoster(MovieMediaItem movie)
|
||||
{
|
||||
string folder = Path.GetDirectoryName(movie.Path) ?? string.Empty;
|
||||
IEnumerable<string> possibleMoviePosters = ImageFileExtensions.Collect(
|
||||
ext => new[] { $"poster.{ext}", Path.GetFileNameWithoutExtension(movie.Path) + $"-poster.{ext}" })
|
||||
.Map(f => Path.Combine(folder, f));
|
||||
Option<string> result = possibleMoviePosters.Filter(p => _localFileSystem.FileExists(p)).HeadOrNone();
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
namespace ErsatzTV.Core.Metadata
|
||||
{
|
||||
public enum ScanningAction
|
||||
{
|
||||
None = 0,
|
||||
Add = 1,
|
||||
Remove = 2,
|
||||
Statistics = 3,
|
||||
SidecarMetadata = 4,
|
||||
FallbackMetadata = 5,
|
||||
Collections = 6,
|
||||
Poster = 7
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
namespace ErsatzTV.Core.Metadata
|
||||
{
|
||||
public enum ScanningMode
|
||||
{
|
||||
Default = 0,
|
||||
RescanAll = 1
|
||||
}
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using LanguageExt;
|
||||
using static LanguageExt.Prelude;
|
||||
|
||||
namespace ErsatzTV.Core.Metadata
|
||||
{
|
||||
public class SmartCollectionBuilder : ISmartCollectionBuilder
|
||||
{
|
||||
private readonly IMediaCollectionRepository _mediaCollectionRepository;
|
||||
|
||||
public SmartCollectionBuilder(IMediaCollectionRepository mediaCollectionRepository) =>
|
||||
_mediaCollectionRepository = mediaCollectionRepository;
|
||||
|
||||
public async Task<bool> RefreshSmartCollections(MediaItem mediaItem)
|
||||
{
|
||||
var results = new List<bool>();
|
||||
|
||||
foreach (TelevisionMediaCollection collection in GetTelevisionCollections(mediaItem))
|
||||
{
|
||||
results.Add(await _mediaCollectionRepository.InsertOrIgnore(collection));
|
||||
}
|
||||
|
||||
return results.Any(identity);
|
||||
}
|
||||
|
||||
private IEnumerable<TelevisionMediaCollection> GetTelevisionCollections(MediaItem mediaItem)
|
||||
{
|
||||
IList<MediaItem> televisionMediaItems = new[] { mediaItem }
|
||||
.Where(c => c.Metadata.MediaType == MediaType.TvShow)
|
||||
.ToList();
|
||||
|
||||
IEnumerable<TelevisionMediaCollection> televisionShowCollections = televisionMediaItems
|
||||
.Map(c => c.Metadata.Title)
|
||||
.Distinct().Map(
|
||||
t => new TelevisionMediaCollection
|
||||
{
|
||||
Name = $"{t} - All Seasons",
|
||||
ShowTitle = t,
|
||||
SeasonNumber = null
|
||||
});
|
||||
|
||||
IEnumerable<TelevisionMediaCollection> televisionShowSeasonCollections = televisionMediaItems
|
||||
.Map(c => new { c.Metadata.Title, c.Metadata.SeasonNumber }).Distinct()
|
||||
.Map(
|
||||
ts =>
|
||||
{
|
||||
return Optional(ts.SeasonNumber).Map(
|
||||
sn => new TelevisionMediaCollection
|
||||
{
|
||||
Name = $"{ts.Title} - Season {sn:00}",
|
||||
ShowTitle = ts.Title,
|
||||
SeasonNumber = sn
|
||||
});
|
||||
})
|
||||
.Sequence().Flatten();
|
||||
|
||||
return Seq(televisionShowCollections, televisionShowSeasonCollections).Flatten();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,361 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Images;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using LanguageExt;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using static LanguageExt.Prelude;
|
||||
|
||||
namespace ErsatzTV.Core.Metadata
|
||||
{
|
||||
public class TelevisionFolderScanner : LocalFolderScanner, ITelevisionFolderScanner
|
||||
{
|
||||
private readonly ILocalFileSystem _localFileSystem;
|
||||
private readonly ILocalMetadataProvider _localMetadataProvider;
|
||||
private readonly ILogger<TelevisionFolderScanner> _logger;
|
||||
private readonly ITelevisionRepository _televisionRepository;
|
||||
|
||||
public TelevisionFolderScanner(
|
||||
ILocalFileSystem localFileSystem,
|
||||
ITelevisionRepository televisionRepository,
|
||||
ILocalStatisticsProvider localStatisticsProvider,
|
||||
ILocalMetadataProvider localMetadataProvider,
|
||||
IImageCache imageCache,
|
||||
ILogger<TelevisionFolderScanner> logger) : base(
|
||||
localFileSystem,
|
||||
localStatisticsProvider,
|
||||
imageCache,
|
||||
logger)
|
||||
{
|
||||
_localFileSystem = localFileSystem;
|
||||
_televisionRepository = televisionRepository;
|
||||
_localMetadataProvider = localMetadataProvider;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<Unit> ScanFolder(LocalMediaSource localMediaSource, string ffprobePath)
|
||||
{
|
||||
if (!_localFileSystem.IsMediaSourceAccessible(localMediaSource))
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Media source is not accessible or missing; skipping scan of {Folder}",
|
||||
localMediaSource.Folder);
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
var allShowFolders = _localFileSystem.ListSubdirectories(localMediaSource.Folder)
|
||||
.Filter(ShouldIncludeFolder)
|
||||
.OrderBy(identity)
|
||||
.ToList();
|
||||
|
||||
foreach (string showFolder in allShowFolders)
|
||||
{
|
||||
// TODO: check all sources for latest metadata?
|
||||
Either<BaseError, TelevisionShow> maybeShow =
|
||||
await FindOrCreateShow(localMediaSource.Id, showFolder)
|
||||
.BindT(show => UpdateMetadataForShow(show, showFolder))
|
||||
.BindT(show => UpdatePosterForShow(show, showFolder));
|
||||
|
||||
await maybeShow.Match(
|
||||
show => ScanSeasons(localMediaSource, ffprobePath, show, showFolder),
|
||||
_ => Task.FromResult(Unit.Default));
|
||||
}
|
||||
|
||||
await _televisionRepository.DeleteMissingSources(localMediaSource.Id, allShowFolders);
|
||||
await _televisionRepository.DeleteEmptyShows();
|
||||
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
private async Task<Either<BaseError, TelevisionShow>> FindOrCreateShow(
|
||||
int localMediaSourceId,
|
||||
string showFolder)
|
||||
{
|
||||
Option<TelevisionShow> maybeShowByPath =
|
||||
await _televisionRepository.GetShowByPath(localMediaSourceId, showFolder);
|
||||
return await maybeShowByPath.Match(
|
||||
show => Right<BaseError, TelevisionShow>(show).AsTask(),
|
||||
async () =>
|
||||
{
|
||||
TelevisionShowMetadata metadata = await _localMetadataProvider.GetMetadataForShow(showFolder);
|
||||
Option<TelevisionShow> maybeShow = await _televisionRepository.GetShowByMetadata(metadata);
|
||||
return await maybeShow.Match(
|
||||
async show =>
|
||||
{
|
||||
show.Sources.Add(
|
||||
new LocalTelevisionShowSource
|
||||
{
|
||||
MediaSourceId = localMediaSourceId,
|
||||
Path = showFolder,
|
||||
TelevisionShow = show
|
||||
});
|
||||
await _televisionRepository.Update(show);
|
||||
return Right<BaseError, TelevisionShow>(show);
|
||||
},
|
||||
async () => await _televisionRepository.AddShow(localMediaSourceId, showFolder, metadata));
|
||||
});
|
||||
}
|
||||
|
||||
private async Task<Unit> ScanSeasons(
|
||||
LocalMediaSource localMediaSource,
|
||||
string ffprobePath,
|
||||
TelevisionShow show,
|
||||
string showPath)
|
||||
{
|
||||
foreach (string seasonFolder in _localFileSystem.ListSubdirectories(showPath).Filter(ShouldIncludeFolder)
|
||||
.OrderBy(identity))
|
||||
{
|
||||
Option<int> maybeSeasonNumber = SeasonNumberForFolder(seasonFolder);
|
||||
await maybeSeasonNumber.IfSomeAsync(
|
||||
async seasonNumber =>
|
||||
{
|
||||
Either<BaseError, TelevisionSeason> maybeSeason = await _televisionRepository
|
||||
.GetOrAddSeason(show, seasonFolder, seasonNumber)
|
||||
.BindT(UpdatePoster);
|
||||
|
||||
await maybeSeason.Match(
|
||||
season => ScanEpisodes(localMediaSource, ffprobePath, season),
|
||||
_ => Task.FromResult(Unit.Default));
|
||||
});
|
||||
}
|
||||
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
private async Task<Unit> ScanEpisodes(
|
||||
LocalMediaSource localMediaSource,
|
||||
string ffprobePath,
|
||||
TelevisionSeason season)
|
||||
{
|
||||
foreach (string file in _localFileSystem.ListFiles(season.Path)
|
||||
.Filter(f => VideoFileExtensions.Contains(Path.GetExtension(f))).OrderBy(identity))
|
||||
{
|
||||
// TODO: figure out how to rebuild playlists
|
||||
Either<BaseError, TelevisionEpisodeMediaItem> maybeEpisode = await _televisionRepository
|
||||
.GetOrAddEpisode(season, localMediaSource.Id, file)
|
||||
.BindT(episode => UpdateStatistics(episode, ffprobePath).MapT(_ => episode))
|
||||
.BindT(UpdateMetadata)
|
||||
.BindT(UpdateThumbnail);
|
||||
|
||||
maybeEpisode.IfLeft(
|
||||
error => _logger.LogWarning("Error processing episode at {Path}: {Error}", file, error.Value));
|
||||
}
|
||||
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
private async Task<Either<BaseError, TelevisionShow>> UpdateMetadataForShow(
|
||||
TelevisionShow show,
|
||||
string showFolder)
|
||||
{
|
||||
try
|
||||
{
|
||||
await LocateNfoFileForShow(showFolder).Match(
|
||||
async nfoFile =>
|
||||
{
|
||||
if (show.Metadata == null || show.Metadata.Source == MetadataSource.Fallback ||
|
||||
(show.Metadata.LastWriteTime ?? DateTime.MinValue) <
|
||||
_localFileSystem.GetLastWriteTime(nfoFile))
|
||||
{
|
||||
_logger.LogDebug("Refreshing {Attribute} from {Path}", "Sidecar Metadata", nfoFile);
|
||||
await _localMetadataProvider.RefreshSidecarMetadata(show, nfoFile);
|
||||
}
|
||||
},
|
||||
async () =>
|
||||
{
|
||||
if (show.Metadata == null)
|
||||
{
|
||||
_logger.LogDebug("Refreshing {Attribute} for {Path}", "Fallback Metadata", showFolder);
|
||||
await _localMetadataProvider.RefreshFallbackMetadata(show, showFolder);
|
||||
}
|
||||
});
|
||||
|
||||
return show;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return BaseError.New(ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<Either<BaseError, TelevisionEpisodeMediaItem>> UpdateMetadata(
|
||||
TelevisionEpisodeMediaItem episode)
|
||||
{
|
||||
try
|
||||
{
|
||||
await LocateNfoFile(episode).Match(
|
||||
async nfoFile =>
|
||||
{
|
||||
if (episode.Metadata == null || episode.Metadata.Source == MetadataSource.Fallback ||
|
||||
(episode.Metadata.LastWriteTime ?? DateTime.MinValue) <
|
||||
_localFileSystem.GetLastWriteTime(nfoFile))
|
||||
{
|
||||
_logger.LogDebug("Refreshing {Attribute} from {Path}", "Sidecar Metadata", nfoFile);
|
||||
await _localMetadataProvider.RefreshSidecarMetadata(episode, nfoFile);
|
||||
}
|
||||
},
|
||||
async () =>
|
||||
{
|
||||
if (episode.Metadata == null)
|
||||
{
|
||||
_logger.LogDebug("Refreshing {Attribute} for {Path}", "Fallback Metadata", episode.Path);
|
||||
await _localMetadataProvider.RefreshFallbackMetadata(episode);
|
||||
}
|
||||
});
|
||||
|
||||
return episode;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return BaseError.New(ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<Either<BaseError, TelevisionShow>> UpdatePosterForShow(
|
||||
TelevisionShow show,
|
||||
string showFolder)
|
||||
{
|
||||
try
|
||||
{
|
||||
await LocatePosterForShow(showFolder).IfSomeAsync(
|
||||
async posterFile =>
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(show.Poster) ||
|
||||
(show.PosterLastWriteTime ?? DateTime.MinValue) <
|
||||
_localFileSystem.GetLastWriteTime(posterFile))
|
||||
{
|
||||
_logger.LogDebug("Refreshing {Attribute} from {Path}", "Poster", posterFile);
|
||||
Either<BaseError, string> maybePoster = await SavePosterToDisk(posterFile, 440);
|
||||
await maybePoster.Match(
|
||||
poster =>
|
||||
{
|
||||
show.Poster = poster;
|
||||
show.PosterLastWriteTime = _localFileSystem.GetLastWriteTime(posterFile);
|
||||
return _televisionRepository.Update(show);
|
||||
},
|
||||
error =>
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Unable to save poster to disk from {Path}: {Error}",
|
||||
posterFile,
|
||||
error.Value);
|
||||
return Task.CompletedTask;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return show;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return BaseError.New(ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<Either<BaseError, TelevisionSeason>> UpdatePoster(TelevisionSeason season)
|
||||
{
|
||||
try
|
||||
{
|
||||
await LocatePoster(season).IfSomeAsync(
|
||||
async posterFile =>
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(season.Poster) ||
|
||||
(season.PosterLastWriteTime ?? DateTime.MinValue) <
|
||||
_localFileSystem.GetLastWriteTime(posterFile))
|
||||
{
|
||||
_logger.LogDebug("Refreshing {Attribute} from {Path}", "Poster", posterFile);
|
||||
await SavePosterToDisk(season, posterFile, _televisionRepository.Update, 440);
|
||||
}
|
||||
});
|
||||
|
||||
return season;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return BaseError.New(ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<Either<BaseError, TelevisionEpisodeMediaItem>> UpdateThumbnail(
|
||||
TelevisionEpisodeMediaItem episode)
|
||||
{
|
||||
try
|
||||
{
|
||||
await LocateThumbnail(episode).IfSomeAsync(
|
||||
async posterFile =>
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(episode.Poster) ||
|
||||
(episode.PosterLastWriteTime ?? DateTime.MinValue) <
|
||||
_localFileSystem.GetLastWriteTime(posterFile))
|
||||
{
|
||||
_logger.LogDebug("Refreshing {Attribute} from {Path}", "Thumbnail", posterFile);
|
||||
await SavePosterToDisk(episode, posterFile, _televisionRepository.Update);
|
||||
}
|
||||
});
|
||||
|
||||
return episode;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return BaseError.New(ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private Option<string> LocateNfoFileForShow(string showFolder) =>
|
||||
Optional(Path.Combine(showFolder, "tvshow.nfo"))
|
||||
.Filter(s => _localFileSystem.FileExists(s));
|
||||
|
||||
private Option<string> LocateNfoFile(TelevisionEpisodeMediaItem episode) =>
|
||||
Optional(Path.ChangeExtension(episode.Path, "nfo"))
|
||||
.Filter(s => _localFileSystem.FileExists(s));
|
||||
|
||||
private Option<string> LocatePosterForShow(string showFolder) =>
|
||||
ImageFileExtensions
|
||||
.Map(ext => $"poster.{ext}")
|
||||
.Map(f => Path.Combine(showFolder, f))
|
||||
.Filter(s => _localFileSystem.FileExists(s))
|
||||
.HeadOrNone();
|
||||
|
||||
private Option<string> LocatePoster(TelevisionSeason season)
|
||||
{
|
||||
string folder = Path.GetDirectoryName(season.Path) ?? string.Empty;
|
||||
return ImageFileExtensions
|
||||
.Map(ext => Path.Combine(folder, $"season{season.Number:00}-poster.{ext}"))
|
||||
.Filter(s => _localFileSystem.FileExists(s))
|
||||
.HeadOrNone();
|
||||
}
|
||||
|
||||
private Option<string> LocateThumbnail(TelevisionEpisodeMediaItem episode)
|
||||
{
|
||||
string folder = Path.GetDirectoryName(episode.Path) ?? string.Empty;
|
||||
return ImageFileExtensions
|
||||
.Map(ext => Path.GetFileNameWithoutExtension(episode.Path) + $"-thumb.{ext}")
|
||||
.Map(f => Path.Combine(folder, f))
|
||||
.Filter(f => _localFileSystem.FileExists(f))
|
||||
.HeadOrNone();
|
||||
}
|
||||
|
||||
private bool ShouldIncludeFolder(string folder) =>
|
||||
!Path.GetFileName(folder).StartsWith('.') &&
|
||||
!_localFileSystem.FileExists(Path.Combine(folder, ".etvignore"));
|
||||
|
||||
private static Option<int> SeasonNumberForFolder(string folder)
|
||||
{
|
||||
if (int.TryParse(folder.Split(" ").Last(), out int seasonNumber))
|
||||
{
|
||||
return seasonNumber;
|
||||
}
|
||||
|
||||
if (folder.EndsWith("specials", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
return None;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user