Database redesign (#31)
* starting database redesign * set season and episode numbers * use datetimes in db (utc); update movie metadata * get movie cards from new table * copy show/episode metadata * remove old movie metadata type * rename new movie metadata type * code cleanup * start to remove old television classes * remove old television tables from database * fix playout building * fix collection views * fix show/season views * clean up movie metadata table * fix scanner tests * add libraries ui * code cleanup * fix movie scanning/metadata * add library scan button to ui * delete library path from ui * temp disable movie scanning * remove orphan media items and prevent duplicate paths * attach artwork to metadata * fix split show/season display * fix television artwork * store year distinct from release date * fix collections ui * code cleanup * add library paths from ui * fix adding to collections from ui * fix schedule items loading * schedule editing works again * remove some todos * more cleanup * fix unit tests * fix episode sorting * fix deleting show library paths * remove unused class * fix playout list in ui * fix log viewer * start to use version/file instead of statistics * clean up old columns * fix playout display (time zone) * fix playback * fix channel guide time zone * cascade more deletes * fix compiler warnings * fix adding new seasons * use artwork for channel logo * clean cache folder on startup (move channel logos, delete everything else) * log database migration * update homepage docs for libraries * fix adding new channel with logo * fix episode numbers in epg
This commit is contained in:
@@ -3,57 +3,39 @@ using System.IO;
|
||||
using System.Text.RegularExpressions;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using static LanguageExt.Prelude;
|
||||
|
||||
namespace ErsatzTV.Core.Metadata
|
||||
{
|
||||
public class FallbackMetadataProvider : IFallbackMetadataProvider
|
||||
{
|
||||
public TelevisionShowMetadata GetFallbackMetadataForShow(string showFolder)
|
||||
public ShowMetadata GetFallbackMetadataForShow(string showFolder)
|
||||
{
|
||||
string fileName = Path.GetFileName(showFolder);
|
||||
var metadata = new TelevisionShowMetadata
|
||||
{ Source = MetadataSource.Fallback, Title = fileName ?? showFolder };
|
||||
var metadata = new ShowMetadata
|
||||
{ MetadataKind = MetadataKind.Fallback, Title = fileName ?? showFolder };
|
||||
return GetTelevisionShowMetadata(fileName, metadata);
|
||||
}
|
||||
|
||||
public static TelevisionEpisodeMetadata GetFallbackMetadata(TelevisionEpisodeMediaItem mediaItem)
|
||||
public static Tuple<EpisodeMetadata, int> GetFallbackMetadata(Episode episode)
|
||||
{
|
||||
string fileName = Path.GetFileName(mediaItem.Path);
|
||||
var metadata = new TelevisionEpisodeMetadata
|
||||
{ Source = MetadataSource.Fallback, Title = fileName ?? mediaItem.Path };
|
||||
|
||||
if (fileName != null)
|
||||
{
|
||||
if (!(mediaItem.Source is LocalMediaSource))
|
||||
{
|
||||
return metadata;
|
||||
}
|
||||
|
||||
return GetEpisodeMetadata(fileName, metadata);
|
||||
}
|
||||
|
||||
return metadata;
|
||||
string path = episode.MediaVersions.Head().MediaFiles.Head().Path;
|
||||
string fileName = Path.GetFileName(path);
|
||||
var metadata = new EpisodeMetadata
|
||||
{ MetadataKind = MetadataKind.Fallback, Title = fileName ?? path };
|
||||
return fileName != null ? GetEpisodeMetadata(fileName, metadata) : Tuple(metadata, 0);
|
||||
}
|
||||
|
||||
public static MovieMetadata GetFallbackMetadata(MovieMediaItem mediaItem)
|
||||
public static MovieMetadata GetFallbackMetadata(Movie movie)
|
||||
{
|
||||
string fileName = Path.GetFileName(mediaItem.Path);
|
||||
var metadata = new MovieMetadata { Source = MetadataSource.Fallback, Title = fileName ?? mediaItem.Path };
|
||||
string path = movie.MediaVersions.Head().MediaFiles.Head().Path;
|
||||
string fileName = Path.GetFileName(path);
|
||||
var metadata = new MovieMetadata { MetadataKind = MetadataKind.Fallback, Title = fileName ?? path };
|
||||
|
||||
if (fileName != null)
|
||||
{
|
||||
if (!(mediaItem.Source is LocalMediaSource))
|
||||
{
|
||||
return metadata;
|
||||
}
|
||||
|
||||
return GetMovieMetadata(fileName, metadata);
|
||||
}
|
||||
|
||||
return metadata;
|
||||
return fileName != null ? GetMovieMetadata(fileName, metadata) : metadata;
|
||||
}
|
||||
|
||||
private static TelevisionEpisodeMetadata GetEpisodeMetadata(string fileName, TelevisionEpisodeMetadata metadata)
|
||||
private static Tuple<EpisodeMetadata, int> GetEpisodeMetadata(string fileName, EpisodeMetadata metadata)
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -62,8 +44,7 @@ namespace ErsatzTV.Core.Metadata
|
||||
if (match.Success)
|
||||
{
|
||||
metadata.Title = match.Groups[1].Value;
|
||||
metadata.Season = int.Parse(match.Groups[2].Value);
|
||||
metadata.Episode = int.Parse(match.Groups[3].Value);
|
||||
return Tuple(metadata, int.Parse(match.Groups[3].Value));
|
||||
}
|
||||
}
|
||||
catch (Exception)
|
||||
@@ -71,7 +52,7 @@ namespace ErsatzTV.Core.Metadata
|
||||
// ignored
|
||||
}
|
||||
|
||||
return metadata;
|
||||
return Tuple(metadata, 0);
|
||||
}
|
||||
|
||||
private static MovieMetadata GetMovieMetadata(string fileName, MovieMetadata metadata)
|
||||
@@ -84,6 +65,7 @@ namespace ErsatzTV.Core.Metadata
|
||||
{
|
||||
metadata.Title = match.Groups[1].Value;
|
||||
metadata.Year = int.Parse(match.Groups[2].Value);
|
||||
metadata.ReleaseDate = new DateTime(int.Parse(match.Groups[2].Value), 1, 1);
|
||||
}
|
||||
}
|
||||
catch (Exception)
|
||||
@@ -94,9 +76,9 @@ namespace ErsatzTV.Core.Metadata
|
||||
return metadata;
|
||||
}
|
||||
|
||||
private static TelevisionShowMetadata GetTelevisionShowMetadata(
|
||||
private static ShowMetadata GetTelevisionShowMetadata(
|
||||
string fileName,
|
||||
TelevisionShowMetadata metadata)
|
||||
ShowMetadata metadata)
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -106,6 +88,7 @@ namespace ErsatzTV.Core.Metadata
|
||||
{
|
||||
metadata.Title = match.Groups[1].Value;
|
||||
metadata.Year = int.Parse(match.Groups[2].Value);
|
||||
metadata.ReleaseDate = new DateTime(int.Parse(match.Groups[2].Value), 1, 1);
|
||||
}
|
||||
}
|
||||
catch (Exception)
|
||||
|
||||
@@ -4,6 +4,7 @@ using System.IO;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using LanguageExt;
|
||||
using static LanguageExt.Prelude;
|
||||
|
||||
namespace ErsatzTV.Core.Metadata
|
||||
@@ -13,8 +14,8 @@ namespace ErsatzTV.Core.Metadata
|
||||
public DateTime GetLastWriteTime(string path) =>
|
||||
Try(File.GetLastWriteTimeUtc(path)).IfFail(() => DateTime.MinValue);
|
||||
|
||||
public bool IsMediaSourceAccessible(LocalMediaSource localMediaSource) =>
|
||||
Directory.Exists(localMediaSource.Folder);
|
||||
public bool IsLibraryPathAccessible(LibraryPath libraryPath) =>
|
||||
Directory.Exists(libraryPath.Path);
|
||||
|
||||
public IEnumerable<string> ListSubdirectories(string folder) =>
|
||||
Try(Directory.EnumerateDirectories(folder)).IfFail(new List<string>());
|
||||
@@ -24,5 +25,18 @@ namespace ErsatzTV.Core.Metadata
|
||||
|
||||
public bool FileExists(string path) => File.Exists(path);
|
||||
public Task<byte[]> ReadAllBytes(string path) => File.ReadAllBytesAsync(path);
|
||||
|
||||
public Unit CopyFile(string source, string destination)
|
||||
{
|
||||
string directory = Path.GetDirectoryName(destination) ?? string.Empty;
|
||||
if (!Directory.Exists(directory))
|
||||
{
|
||||
Directory.CreateDirectory(directory);
|
||||
}
|
||||
|
||||
File.Copy(source, destination, true);
|
||||
|
||||
return Unit.Default;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,18 +2,21 @@
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Security.Cryptography;
|
||||
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;
|
||||
using static LanguageExt.Prelude;
|
||||
|
||||
namespace ErsatzTV.Core.Metadata
|
||||
{
|
||||
public abstract class LocalFolderScanner
|
||||
{
|
||||
private static readonly SHA1CryptoServiceProvider Crypto;
|
||||
|
||||
public static readonly List<string> VideoFileExtensions = new()
|
||||
{
|
||||
".mpg", ".mp2", ".mpeg", ".mpe", ".mpv", ".ogg", ".mp4",
|
||||
@@ -46,6 +49,8 @@ namespace ErsatzTV.Core.Metadata
|
||||
private readonly ILocalStatisticsProvider _localStatisticsProvider;
|
||||
private readonly ILogger _logger;
|
||||
|
||||
static LocalFolderScanner() => Crypto = new SHA1CryptoServiceProvider();
|
||||
|
||||
protected LocalFolderScanner(
|
||||
ILocalFileSystem localFileSystem,
|
||||
ILocalStatisticsProvider localStatisticsProvider,
|
||||
@@ -63,11 +68,18 @@ namespace ErsatzTV.Core.Metadata
|
||||
{
|
||||
try
|
||||
{
|
||||
if (mediaItem.Statistics is null ||
|
||||
(mediaItem.Statistics.LastWriteTime ?? DateTime.MinValue) <
|
||||
_localFileSystem.GetLastWriteTime(mediaItem.Path))
|
||||
MediaVersion version = mediaItem switch
|
||||
{
|
||||
_logger.LogDebug("Refreshing {Attribute} for {Path}", "Statistics", mediaItem.Path);
|
||||
Movie m => m.MediaVersions.Head(),
|
||||
Episode e => e.MediaVersions.Head(),
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(mediaItem))
|
||||
};
|
||||
|
||||
string path = version.MediaFiles.Head().Path;
|
||||
|
||||
if (version.DateUpdated < _localFileSystem.GetLastWriteTime(path))
|
||||
{
|
||||
_logger.LogDebug("Refreshing {Attribute} for {Path}", "Statistics", path);
|
||||
await _localStatisticsProvider.RefreshStatistics(ffprobePath, mediaItem);
|
||||
}
|
||||
|
||||
@@ -79,30 +91,47 @@ namespace ErsatzTV.Core.Metadata
|
||||
}
|
||||
}
|
||||
|
||||
protected async Task SavePosterToDisk<T>(
|
||||
T show,
|
||||
string posterPath,
|
||||
Func<T, Task<bool>> update,
|
||||
int height = 220) where T : IHasAPoster
|
||||
protected bool RefreshArtwork(string artworkFile, Domain.Metadata metadata, ArtworkKind artworkKind)
|
||||
{
|
||||
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;
|
||||
});
|
||||
}
|
||||
DateTime lastWriteTime = _localFileSystem.GetLastWriteTime(artworkFile);
|
||||
|
||||
protected Task<Either<BaseError, string>> SavePosterToDisk(string posterPath, int height = 220) =>
|
||||
_localFileSystem.ReadAllBytes(posterPath)
|
||||
.Bind(bytes => _imageCache.ResizeAndSaveImage(bytes, height, null));
|
||||
metadata.Artwork ??= new List<Artwork>();
|
||||
|
||||
Option<Artwork> maybePoster =
|
||||
Optional(metadata.Artwork).Flatten().FirstOrDefault(a => a.ArtworkKind == artworkKind);
|
||||
|
||||
bool shouldRefresh = maybePoster.Match(
|
||||
artwork => artwork.DateUpdated < lastWriteTime,
|
||||
true);
|
||||
|
||||
if (shouldRefresh)
|
||||
{
|
||||
_logger.LogDebug("Refreshing {Attribute} from {Path}", artworkKind, artworkFile);
|
||||
string cacheName = _imageCache.CopyArtworkToCache(artworkFile, artworkKind);
|
||||
|
||||
maybePoster.Match(
|
||||
artwork =>
|
||||
{
|
||||
artwork.Path = cacheName;
|
||||
artwork.DateUpdated = lastWriteTime;
|
||||
},
|
||||
() =>
|
||||
{
|
||||
var artwork = new Artwork
|
||||
{
|
||||
Path = cacheName,
|
||||
DateAdded = DateTime.UtcNow,
|
||||
DateUpdated = lastWriteTime,
|
||||
ArtworkKind = artworkKind
|
||||
};
|
||||
|
||||
metadata.Artwork.Add(artwork);
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Threading.Tasks;
|
||||
using System.Xml.Serialization;
|
||||
@@ -37,94 +38,132 @@ namespace ErsatzTV.Core.Metadata
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public Task<TelevisionShowMetadata> GetMetadataForShow(string showFolder)
|
||||
public async Task<ShowMetadata> GetMetadataForShow(string showFolder)
|
||||
{
|
||||
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;
|
||||
});
|
||||
Option<ShowMetadata> maybeMetadata = None;
|
||||
if (_localFileSystem.FileExists(nfoFileName))
|
||||
{
|
||||
maybeMetadata = await LoadTelevisionShowMetadata(nfoFileName);
|
||||
}
|
||||
|
||||
return maybeMetadata.IfNone(
|
||||
() =>
|
||||
{
|
||||
ShowMetadata metadata = _fallbackMetadataProvider.GetFallbackMetadataForShow(showFolder);
|
||||
metadata.SortTitle = GetSortTitle(metadata.Title);
|
||||
return metadata;
|
||||
});
|
||||
}
|
||||
|
||||
public Task<Unit> RefreshSidecarMetadata(MediaItem mediaItem, string path) =>
|
||||
mediaItem switch
|
||||
{
|
||||
TelevisionEpisodeMediaItem e => LoadMetadata(e, path)
|
||||
Episode e => LoadMetadata(e, path)
|
||||
.Bind(maybeMetadata => maybeMetadata.IfSomeAsync(metadata => ApplyMetadataUpdate(e, metadata))),
|
||||
MovieMediaItem m => LoadMetadata(m, path)
|
||||
Movie m => LoadMetadata(m, path)
|
||||
.Bind(maybeMetadata => maybeMetadata.IfSomeAsync(metadata => ApplyMetadataUpdate(m, metadata))),
|
||||
_ => Task.FromResult(Unit.Default)
|
||||
};
|
||||
|
||||
public Task<Unit> RefreshSidecarMetadata(TelevisionShow televisionShow, string showFolder) =>
|
||||
public Task<Unit> RefreshSidecarMetadata(Show 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))
|
||||
Episode e => ApplyMetadataUpdate(e, FallbackMetadataProvider.GetFallbackMetadata(e))
|
||||
.ToUnit(),
|
||||
MovieMediaItem m => ApplyMetadataUpdate(m, FallbackMetadataProvider.GetFallbackMetadata(m)).ToUnit(),
|
||||
Movie m => ApplyMetadataUpdate(m, FallbackMetadataProvider.GetFallbackMetadata(m)).ToUnit(),
|
||||
_ => Task.FromResult(Unit.Default)
|
||||
};
|
||||
|
||||
public Task<Unit> RefreshFallbackMetadata(TelevisionShow televisionShow, string showFolder) =>
|
||||
public Task<Unit> RefreshFallbackMetadata(Show televisionShow, string showFolder) =>
|
||||
ApplyMetadataUpdate(televisionShow, _fallbackMetadataProvider.GetFallbackMetadataForShow(showFolder))
|
||||
.ToUnit();
|
||||
|
||||
private async Task ApplyMetadataUpdate(TelevisionEpisodeMediaItem mediaItem, TelevisionEpisodeMetadata metadata)
|
||||
private async Task ApplyMetadataUpdate(Episode episode, Tuple<EpisodeMetadata, int> metadataEpisodeNumber)
|
||||
{
|
||||
mediaItem.Metadata ??= new TelevisionEpisodeMetadata { TelevisionEpisodeId = mediaItem.Id };
|
||||
mediaItem.Metadata.Source = metadata.Source;
|
||||
mediaItem.Metadata.LastWriteTime = metadata.LastWriteTime;
|
||||
mediaItem.Metadata.Title = metadata.Title;
|
||||
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;
|
||||
(EpisodeMetadata metadata, int episodeNumber) = metadataEpisodeNumber;
|
||||
episode.EpisodeNumber = episodeNumber;
|
||||
Optional(episode.EpisodeMetadata).Flatten().HeadOrNone().Match(
|
||||
existing =>
|
||||
{
|
||||
existing.Outline = metadata.Outline;
|
||||
existing.Plot = metadata.Plot;
|
||||
existing.Tagline = metadata.Tagline;
|
||||
existing.Title = metadata.Title;
|
||||
existing.DateAdded = metadata.DateAdded;
|
||||
existing.DateUpdated = metadata.DateUpdated;
|
||||
existing.MetadataKind = metadata.MetadataKind;
|
||||
existing.OriginalTitle = metadata.OriginalTitle;
|
||||
existing.ReleaseDate = metadata.ReleaseDate;
|
||||
existing.Year = metadata.Year;
|
||||
existing.SortTitle = metadata.SortTitle ?? GetSortTitle(metadata.Title);
|
||||
},
|
||||
() =>
|
||||
{
|
||||
metadata.SortTitle ??= GetSortTitle(metadata.Title);
|
||||
episode.EpisodeMetadata = new List<EpisodeMetadata> { metadata };
|
||||
});
|
||||
|
||||
await _televisionRepository.Update(mediaItem);
|
||||
await _televisionRepository.Update(episode);
|
||||
}
|
||||
|
||||
private async Task ApplyMetadataUpdate(MovieMediaItem mediaItem, MovieMetadata metadata)
|
||||
private async Task ApplyMetadataUpdate(Movie movie, 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;
|
||||
Optional(movie.MovieMetadata).Flatten().HeadOrNone().Match(
|
||||
existing =>
|
||||
{
|
||||
existing.Outline = metadata.Outline;
|
||||
existing.Plot = metadata.Plot;
|
||||
existing.Tagline = metadata.Tagline;
|
||||
existing.Title = metadata.Title;
|
||||
existing.DateAdded = metadata.DateAdded;
|
||||
existing.DateUpdated = metadata.DateUpdated;
|
||||
existing.MetadataKind = metadata.MetadataKind;
|
||||
existing.OriginalTitle = metadata.OriginalTitle;
|
||||
existing.ReleaseDate = metadata.ReleaseDate;
|
||||
existing.Year = metadata.Year;
|
||||
existing.SortTitle = metadata.SortTitle ?? GetSortTitle(metadata.Title);
|
||||
},
|
||||
() =>
|
||||
{
|
||||
metadata.SortTitle ??= GetSortTitle(metadata.Title);
|
||||
movie.MovieMetadata = new List<MovieMetadata> { metadata };
|
||||
});
|
||||
|
||||
await _mediaItemRepository.Update(mediaItem);
|
||||
await _mediaItemRepository.Update(movie);
|
||||
}
|
||||
|
||||
private async Task ApplyMetadataUpdate(TelevisionShow televisionShow, TelevisionShowMetadata metadata)
|
||||
private async Task ApplyMetadataUpdate(Show show, ShowMetadata metadata)
|
||||
{
|
||||
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);
|
||||
Optional(show.ShowMetadata).Flatten().HeadOrNone().Match(
|
||||
existing =>
|
||||
{
|
||||
existing.Outline = metadata.Outline;
|
||||
existing.Plot = metadata.Plot;
|
||||
existing.Tagline = metadata.Tagline;
|
||||
existing.Title = metadata.Title;
|
||||
existing.DateAdded = metadata.DateAdded;
|
||||
existing.DateUpdated = metadata.DateUpdated;
|
||||
existing.MetadataKind = metadata.MetadataKind;
|
||||
existing.OriginalTitle = metadata.OriginalTitle;
|
||||
existing.ReleaseDate = metadata.ReleaseDate;
|
||||
existing.Year = metadata.Year;
|
||||
existing.SortTitle = metadata.SortTitle ?? GetSortTitle(metadata.Title);
|
||||
},
|
||||
() =>
|
||||
{
|
||||
metadata.SortTitle ??= GetSortTitle(metadata.Title);
|
||||
show.ShowMetadata = new List<ShowMetadata> { metadata };
|
||||
});
|
||||
|
||||
await _televisionRepository.Update(televisionShow);
|
||||
await _televisionRepository.Update(show);
|
||||
}
|
||||
|
||||
private async Task<Option<MovieMetadata>> LoadMetadata(MovieMediaItem mediaItem, string nfoFileName)
|
||||
private async Task<Option<MovieMetadata>> LoadMetadata(Movie mediaItem, string nfoFileName)
|
||||
{
|
||||
if (nfoFileName == null || !File.Exists(nfoFileName))
|
||||
{
|
||||
@@ -132,18 +171,10 @@ namespace ErsatzTV.Core.Metadata
|
||||
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 LoadMovieMetadata(mediaItem, nfoFileName);
|
||||
}
|
||||
|
||||
private async Task<Option<TelevisionEpisodeMetadata>> LoadMetadata(
|
||||
TelevisionEpisodeMediaItem mediaItem,
|
||||
string nfoFileName)
|
||||
private async Task<Option<Tuple<EpisodeMetadata, int>>> LoadMetadata(Episode mediaItem, string nfoFileName)
|
||||
{
|
||||
if (nfoFileName == null || !File.Exists(nfoFileName))
|
||||
{
|
||||
@@ -151,18 +182,10 @@ namespace ErsatzTV.Core.Metadata
|
||||
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)
|
||||
private async Task<Option<ShowMetadata>> LoadMetadata(Show televisionShow, string nfoFileName)
|
||||
{
|
||||
if (nfoFileName == null || !File.Exists(nfoFileName))
|
||||
{
|
||||
@@ -173,20 +196,23 @@ namespace ErsatzTV.Core.Metadata
|
||||
return await LoadTelevisionShowMetadata(nfoFileName);
|
||||
}
|
||||
|
||||
private async Task<Option<TelevisionShowMetadata>> LoadTelevisionShowMetadata(string nfoFileName)
|
||||
private async Task<Option<ShowMetadata>> LoadTelevisionShowMetadata(string nfoFileName)
|
||||
{
|
||||
try
|
||||
{
|
||||
await using FileStream fileStream = File.Open(nfoFileName, FileMode.Open, FileAccess.Read);
|
||||
Option<TvShowNfo> maybeNfo = TvShowSerializer.Deserialize(fileStream) as TvShowNfo;
|
||||
return maybeNfo.Match<Option<TelevisionShowMetadata>>(
|
||||
nfo => new TelevisionShowMetadata
|
||||
return maybeNfo.Match<Option<ShowMetadata>>(
|
||||
nfo => new ShowMetadata
|
||||
{
|
||||
Source = MetadataSource.Sidecar,
|
||||
LastWriteTime = File.GetLastWriteTimeUtc(nfoFileName),
|
||||
MetadataKind = MetadataKind.Sidecar,
|
||||
DateUpdated = File.GetLastWriteTimeUtc(nfoFileName),
|
||||
Title = nfo.Title,
|
||||
Plot = nfo.Plot,
|
||||
Year = nfo.Year
|
||||
Outline = nfo.Outline,
|
||||
Tagline = nfo.Tagline,
|
||||
Year = nfo.Year,
|
||||
ReleaseDate = GetAired(nfo.Premiered) ?? new DateTime(nfo.Year, 1, 1)
|
||||
},
|
||||
None);
|
||||
}
|
||||
@@ -197,35 +223,35 @@ namespace ErsatzTV.Core.Metadata
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<Option<TelevisionEpisodeMetadata>> LoadEpisodeMetadata(
|
||||
TelevisionEpisodeMediaItem mediaItem,
|
||||
string nfoFileName)
|
||||
private async Task<Option<Tuple<EpisodeMetadata, int>>> LoadEpisodeMetadata(Episode episode, 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
|
||||
return maybeNfo.Match<Option<Tuple<EpisodeMetadata, int>>>(
|
||||
nfo =>
|
||||
{
|
||||
Source = MetadataSource.Sidecar,
|
||||
LastWriteTime = File.GetLastWriteTimeUtc(nfoFileName),
|
||||
Title = nfo.Title,
|
||||
Aired = GetAired(nfo.Aired),
|
||||
Episode = nfo.Episode,
|
||||
Season = nfo.Season,
|
||||
Plot = nfo.Plot
|
||||
var metadata = new EpisodeMetadata
|
||||
{
|
||||
MetadataKind = MetadataKind.Sidecar,
|
||||
DateUpdated = File.GetLastWriteTimeUtc(nfoFileName),
|
||||
Title = nfo.Title,
|
||||
ReleaseDate = GetAired(nfo.Aired),
|
||||
Plot = nfo.Plot
|
||||
};
|
||||
return Tuple(metadata, nfo.Episode);
|
||||
},
|
||||
None);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "Failed to read TV episode nfo metadata from {Path}", nfoFileName);
|
||||
return FallbackMetadataProvider.GetFallbackMetadata(mediaItem);
|
||||
return FallbackMetadataProvider.GetFallbackMetadata(episode);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<Option<MovieMetadata>> LoadMovieMetadata(MovieMediaItem mediaItem, string nfoFileName)
|
||||
private async Task<Option<MovieMetadata>> LoadMovieMetadata(Movie mediaItem, string nfoFileName)
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -234,15 +260,14 @@ namespace ErsatzTV.Core.Metadata
|
||||
return maybeNfo.Match<Option<MovieMetadata>>(
|
||||
nfo => new MovieMetadata
|
||||
{
|
||||
Source = MetadataSource.Sidecar,
|
||||
LastWriteTime = File.GetLastWriteTimeUtc(nfoFileName),
|
||||
MetadataKind = MetadataKind.Sidecar,
|
||||
DateUpdated = File.GetLastWriteTimeUtc(nfoFileName),
|
||||
Title = nfo.Title,
|
||||
Year = nfo.Year,
|
||||
Premiered = nfo.Premiered,
|
||||
ReleaseDate = nfo.Premiered,
|
||||
Plot = nfo.Plot,
|
||||
Outline = nfo.Outline,
|
||||
Tagline = nfo.Tagline,
|
||||
ContentRating = nfo.ContentRating
|
||||
Tagline = nfo.Tagline
|
||||
},
|
||||
None);
|
||||
}
|
||||
@@ -324,6 +349,15 @@ namespace ErsatzTV.Core.Metadata
|
||||
|
||||
[XmlElement("plot")]
|
||||
public string Plot { get; set; }
|
||||
|
||||
[XmlElement("outline")]
|
||||
public string Outline { get; set; }
|
||||
|
||||
[XmlElement("tagline")]
|
||||
public string Tagline { get; set; }
|
||||
|
||||
[XmlElement("premiered")]
|
||||
public string Premiered { get; set; }
|
||||
}
|
||||
|
||||
[XmlRoot("episodedetails")]
|
||||
|
||||
@@ -33,42 +33,49 @@ namespace ErsatzTV.Core.Metadata
|
||||
{
|
||||
try
|
||||
{
|
||||
FFprobe ffprobe = await GetProbeOutput(ffprobePath, mediaItem);
|
||||
MediaItemStatistics statistics = ProjectToMediaItemStatistics(ffprobe);
|
||||
return await ApplyStatisticsUpdate(mediaItem, statistics);
|
||||
string filePath = mediaItem switch
|
||||
{
|
||||
Movie m => m.MediaVersions.Head().MediaFiles.Head().Path,
|
||||
Episode e => e.MediaVersions.Head().MediaFiles.Head().Path,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(mediaItem))
|
||||
};
|
||||
|
||||
FFprobe ffprobe = await GetProbeOutput(ffprobePath, filePath);
|
||||
MediaVersion version = ProjectToMediaVersion(ffprobe);
|
||||
return await ApplyVersionUpdate(mediaItem, version, filePath);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to refresh statistics for media item at {Path}", mediaItem.Path);
|
||||
_logger.LogWarning(ex, "Failed to refresh statistics for media item {Id}", mediaItem.Id);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<bool> ApplyStatisticsUpdate(
|
||||
MediaItem mediaItem,
|
||||
MediaItemStatistics statistics)
|
||||
private async Task<bool> ApplyVersionUpdate(MediaItem mediaItem, MediaVersion version, string filePath)
|
||||
{
|
||||
if (mediaItem.Statistics == null)
|
||||
MediaVersion mediaItemVersion = mediaItem switch
|
||||
{
|
||||
mediaItem.Statistics = new MediaItemStatistics();
|
||||
}
|
||||
Movie m => m.MediaVersions.Head(),
|
||||
Episode e => e.MediaVersions.Head(),
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(mediaItem))
|
||||
};
|
||||
|
||||
bool durationChange = mediaItem.Statistics.Duration != statistics.Duration;
|
||||
bool durationChange = mediaItemVersion.Duration != version.Duration;
|
||||
|
||||
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;
|
||||
mediaItemVersion.DateUpdated = _localFileSystem.GetLastWriteTime(filePath);
|
||||
mediaItemVersion.Duration = version.Duration;
|
||||
mediaItemVersion.AudioCodec = version.AudioCodec;
|
||||
mediaItemVersion.SampleAspectRatio = version.SampleAspectRatio;
|
||||
mediaItemVersion.DisplayAspectRatio = version.DisplayAspectRatio;
|
||||
mediaItemVersion.Width = version.Width;
|
||||
mediaItemVersion.Height = version.Height;
|
||||
mediaItemVersion.VideoCodec = version.VideoCodec;
|
||||
mediaItemVersion.VideoScanKind = version.VideoScanKind;
|
||||
|
||||
return await _mediaItemRepository.Update(mediaItem) && durationChange;
|
||||
}
|
||||
|
||||
private Task<FFprobe> GetProbeOutput(string ffprobePath, MediaItem mediaItem)
|
||||
private Task<FFprobe> GetProbeOutput(string ffprobePath, string filePath)
|
||||
{
|
||||
var startInfo = new ProcessStartInfo
|
||||
{
|
||||
@@ -85,7 +92,7 @@ namespace ErsatzTV.Core.Metadata
|
||||
startInfo.ArgumentList.Add("-show_format");
|
||||
startInfo.ArgumentList.Add("-show_streams");
|
||||
startInfo.ArgumentList.Add("-i");
|
||||
startInfo.ArgumentList.Add(mediaItem.Path);
|
||||
startInfo.ArgumentList.Add(filePath);
|
||||
|
||||
var probe = new Process
|
||||
{
|
||||
@@ -101,7 +108,7 @@ namespace ErsatzTV.Core.Metadata
|
||||
});
|
||||
}
|
||||
|
||||
private MediaItemStatistics ProjectToMediaItemStatistics(FFprobe probeOutput) =>
|
||||
private MediaVersion ProjectToMediaVersion(FFprobe probeOutput) =>
|
||||
Optional(probeOutput)
|
||||
.Filter(json => json?.format != null && json.streams != null)
|
||||
.ToValidation<BaseError>("Unable to parse ffprobe output")
|
||||
@@ -111,41 +118,35 @@ namespace ErsatzTV.Core.Metadata
|
||||
{
|
||||
var duration = TimeSpan.FromSeconds(double.Parse(json.format.duration));
|
||||
|
||||
var statistics = new MediaItemStatistics { Duration = duration };
|
||||
var version = new MediaVersion { Name = "Main", Duration = duration };
|
||||
|
||||
FFprobeStream audioStream = json.streams.FirstOrDefault(s => s.codec_type == "audio");
|
||||
if (audioStream != null)
|
||||
{
|
||||
statistics = statistics with
|
||||
{
|
||||
AudioCodec = audioStream.codec_name
|
||||
};
|
||||
version.AudioCodec = audioStream.codec_name;
|
||||
}
|
||||
|
||||
FFprobeStream videoStream = json.streams.FirstOrDefault(s => s.codec_type == "video");
|
||||
if (videoStream != null)
|
||||
{
|
||||
statistics = statistics with
|
||||
{
|
||||
SampleAspectRatio = videoStream.sample_aspect_ratio,
|
||||
DisplayAspectRatio = videoStream.display_aspect_ratio,
|
||||
Width = videoStream.width,
|
||||
Height = videoStream.height,
|
||||
VideoCodec = videoStream.codec_name,
|
||||
VideoScanType = ScanTypeFromFieldOrder(videoStream.field_order)
|
||||
};
|
||||
version.SampleAspectRatio = videoStream.sample_aspect_ratio;
|
||||
version.DisplayAspectRatio = videoStream.display_aspect_ratio;
|
||||
version.Width = videoStream.width;
|
||||
version.Height = videoStream.height;
|
||||
version.VideoCodec = videoStream.codec_name;
|
||||
version.VideoScanKind = ScanKindFromFieldOrder(videoStream.field_order);
|
||||
}
|
||||
|
||||
return statistics;
|
||||
return version;
|
||||
},
|
||||
_ => new MediaItemStatistics());
|
||||
_ => new MediaVersion { Name = "Main" });
|
||||
|
||||
private VideoScanType ScanTypeFromFieldOrder(string fieldOrder) =>
|
||||
private VideoScanKind ScanKindFromFieldOrder(string fieldOrder) =>
|
||||
fieldOrder?.ToLowerInvariant() switch
|
||||
{
|
||||
var x when x == "tt" || x == "bb" || x == "tb" || x == "bt" => VideoScanType.Interlaced,
|
||||
"progressive" => VideoScanType.Progressive,
|
||||
_ => VideoScanType.Unknown
|
||||
var x when x == "tt" || x == "bb" || x == "tb" || x == "bt" => VideoScanKind.Interlaced,
|
||||
"progressive" => VideoScanKind.Progressive,
|
||||
_ => VideoScanKind.Unknown
|
||||
};
|
||||
|
||||
// ReSharper disable InconsistentNaming
|
||||
|
||||
@@ -37,15 +37,15 @@ namespace ErsatzTV.Core.Metadata
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<Either<BaseError, Unit>> ScanFolder(LocalMediaSource localMediaSource, string ffprobePath)
|
||||
public async Task<Either<BaseError, Unit>> ScanFolder(LibraryPath libraryPath, string ffprobePath)
|
||||
{
|
||||
if (!_localFileSystem.IsMediaSourceAccessible(localMediaSource))
|
||||
if (!_localFileSystem.IsLibraryPathAccessible(libraryPath))
|
||||
{
|
||||
return new MediaSourceInaccessible();
|
||||
}
|
||||
|
||||
var folderQueue = new Queue<string>();
|
||||
foreach (string folder in _localFileSystem.ListSubdirectories(localMediaSource.Folder).OrderBy(identity))
|
||||
foreach (string folder in _localFileSystem.ListSubdirectories(libraryPath.Path).OrderBy(identity))
|
||||
{
|
||||
folderQueue.Enqueue(folder);
|
||||
}
|
||||
@@ -73,10 +73,10 @@ namespace ErsatzTV.Core.Metadata
|
||||
|
||||
foreach (string file in allFiles.OrderBy(identity))
|
||||
{
|
||||
// TODO: optimize dbcontext use here, do we need tracking? can we make partial updates with dapper?
|
||||
// TODO: figure out how to rebuild playlists
|
||||
Either<BaseError, MovieMediaItem> x = await _movieRepository.GetOrAdd(localMediaSource.Id, file);
|
||||
|
||||
Either<BaseError, MovieMediaItem> maybeMovie = await x.AsTask()
|
||||
Either<BaseError, Movie> maybeMovie = await _movieRepository
|
||||
.GetOrAdd(libraryPath, file)
|
||||
.BindT(movie => UpdateStatistics(movie, ffprobePath).MapT(_ => movie))
|
||||
.BindT(UpdateMetadata)
|
||||
.BindT(UpdatePoster);
|
||||
@@ -89,16 +89,19 @@ namespace ErsatzTV.Core.Metadata
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
private async Task<Either<BaseError, MovieMediaItem>> UpdateMetadata(MovieMediaItem movie)
|
||||
private async Task<Either<BaseError, Movie>> UpdateMetadata(Movie 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))
|
||||
bool shouldUpdate = Optional(movie.MovieMetadata).Flatten().HeadOrNone().Match(
|
||||
m => m.MetadataKind == MetadataKind.Fallback ||
|
||||
m.DateUpdated < _localFileSystem.GetLastWriteTime(nfoFile),
|
||||
true);
|
||||
|
||||
if (shouldUpdate)
|
||||
{
|
||||
_logger.LogDebug("Refreshing {Attribute} from {Path}", "Sidecar Metadata", nfoFile);
|
||||
await _localMetadataProvider.RefreshSidecarMetadata(movie, nfoFile);
|
||||
@@ -106,9 +109,10 @@ namespace ErsatzTV.Core.Metadata
|
||||
},
|
||||
async () =>
|
||||
{
|
||||
if (movie.Metadata == null)
|
||||
if (!Optional(movie.MovieMetadata).Flatten().Any())
|
||||
{
|
||||
_logger.LogDebug("Refreshing {Attribute} for {Path}", "Fallback Metadata", movie.Path);
|
||||
string path = movie.MediaVersions.Head().MediaFiles.Head().Path;
|
||||
_logger.LogDebug("Refreshing {Attribute} for {Path}", "Fallback Metadata", path);
|
||||
await _localMetadataProvider.RefreshFallbackMetadata(movie);
|
||||
}
|
||||
});
|
||||
@@ -121,19 +125,17 @@ namespace ErsatzTV.Core.Metadata
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<Either<BaseError, MovieMediaItem>> UpdatePoster(MovieMediaItem movie)
|
||||
private async Task<Either<BaseError, Movie>> UpdatePoster(Movie movie)
|
||||
{
|
||||
try
|
||||
{
|
||||
await LocatePoster(movie).IfSomeAsync(
|
||||
async posterFile =>
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(movie.Poster) ||
|
||||
(movie.PosterLastWriteTime ?? DateTime.MinValue) <
|
||||
_localFileSystem.GetLastWriteTime(posterFile))
|
||||
MovieMetadata metadata = movie.MovieMetadata.Head();
|
||||
if (RefreshArtwork(posterFile, metadata, ArtworkKind.Poster))
|
||||
{
|
||||
_logger.LogDebug("Refreshing {Attribute} from {Path}", "Poster", posterFile);
|
||||
await SavePosterToDisk(movie, posterFile, _movieRepository.Update, 440);
|
||||
await _movieRepository.Update(movie);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -145,20 +147,22 @@ namespace ErsatzTV.Core.Metadata
|
||||
}
|
||||
}
|
||||
|
||||
private Option<string> LocateNfoFile(MovieMediaItem movie)
|
||||
private Option<string> LocateNfoFile(Movie movie)
|
||||
{
|
||||
string movieAsNfo = Path.ChangeExtension(movie.Path, "nfo");
|
||||
string movieNfo = Path.Combine(Path.GetDirectoryName(movie.Path) ?? string.Empty, "movie.nfo");
|
||||
string path = movie.MediaVersions.Head().MediaFiles.Head().Path;
|
||||
string movieAsNfo = Path.ChangeExtension(path, "nfo");
|
||||
string movieNfo = Path.Combine(Path.GetDirectoryName(path) ?? string.Empty, "movie.nfo");
|
||||
return Seq.create(movieAsNfo, movieNfo)
|
||||
.Filter(s => _localFileSystem.FileExists(s))
|
||||
.HeadOrNone();
|
||||
}
|
||||
|
||||
private Option<string> LocatePoster(MovieMediaItem movie)
|
||||
private Option<string> LocatePoster(Movie movie)
|
||||
{
|
||||
string folder = Path.GetDirectoryName(movie.Path) ?? string.Empty;
|
||||
string path = movie.MediaVersions.Head().MediaFiles.Head().Path;
|
||||
string folder = Path.GetDirectoryName(path) ?? string.Empty;
|
||||
IEnumerable<string> possibleMoviePosters = ImageFileExtensions.Collect(
|
||||
ext => new[] { $"poster.{ext}", Path.GetFileNameWithoutExtension(movie.Path) + $"-poster.{ext}" })
|
||||
ext => new[] { $"poster.{ext}", Path.GetFileNameWithoutExtension(path) + $"-poster.{ext}" })
|
||||
.Map(f => Path.Combine(folder, f));
|
||||
Option<string> result = possibleMoviePosters.Filter(p => _localFileSystem.FileExists(p)).HeadOrNone();
|
||||
return result;
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
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;
|
||||
@@ -37,88 +39,65 @@ namespace ErsatzTV.Core.Metadata
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<Unit> ScanFolder(LocalMediaSource localMediaSource, string ffprobePath)
|
||||
public async Task<Either<BaseError, Unit>> ScanFolder(LibraryPath libraryPath, string ffprobePath)
|
||||
{
|
||||
if (!_localFileSystem.IsMediaSourceAccessible(localMediaSource))
|
||||
if (!_localFileSystem.IsLibraryPathAccessible(libraryPath))
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Media source is not accessible or missing; skipping scan of {Folder}",
|
||||
localMediaSource.Folder);
|
||||
return Unit.Default;
|
||||
return new MediaSourceInaccessible();
|
||||
}
|
||||
|
||||
var allShowFolders = _localFileSystem.ListSubdirectories(localMediaSource.Folder)
|
||||
var allShowFolders = _localFileSystem.ListSubdirectories(libraryPath.Path)
|
||||
.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)
|
||||
Either<BaseError, Show> maybeShow =
|
||||
await FindOrCreateShow(libraryPath.Id, showFolder)
|
||||
.BindT(show => UpdateMetadataForShow(show, showFolder))
|
||||
.BindT(show => UpdatePosterForShow(show, showFolder));
|
||||
|
||||
await maybeShow.Match(
|
||||
show => ScanSeasons(localMediaSource, ffprobePath, show, showFolder),
|
||||
show => ScanSeasons(libraryPath, 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,
|
||||
private async Task<Either<BaseError, Show>> FindOrCreateShow(
|
||||
int libraryPathId,
|
||||
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));
|
||||
});
|
||||
ShowMetadata metadata = await _localMetadataProvider.GetMetadataForShow(showFolder);
|
||||
Option<Show> maybeShow = await _televisionRepository.GetShowByMetadata(libraryPathId, metadata);
|
||||
return await maybeShow.Match(
|
||||
show => Right<BaseError, Show>(show).AsTask(),
|
||||
async () => await _televisionRepository.AddShow(libraryPathId, showFolder, metadata));
|
||||
}
|
||||
|
||||
private async Task<Unit> ScanSeasons(
|
||||
LocalMediaSource localMediaSource,
|
||||
LibraryPath libraryPath,
|
||||
string ffprobePath,
|
||||
TelevisionShow show,
|
||||
string showPath)
|
||||
Show show,
|
||||
string showFolder)
|
||||
{
|
||||
foreach (string seasonFolder in _localFileSystem.ListSubdirectories(showPath).Filter(ShouldIncludeFolder)
|
||||
foreach (string seasonFolder in _localFileSystem.ListSubdirectories(showFolder).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);
|
||||
Either<BaseError, Season> maybeSeason = await _televisionRepository
|
||||
.GetOrAddSeason(show, libraryPath.Id, seasonNumber)
|
||||
.BindT(season => UpdatePoster(season, seasonFolder));
|
||||
|
||||
await maybeSeason.Match(
|
||||
season => ScanEpisodes(localMediaSource, ffprobePath, season),
|
||||
season => ScanEpisodes(libraryPath, ffprobePath, season, seasonFolder),
|
||||
_ => Task.FromResult(Unit.Default));
|
||||
});
|
||||
}
|
||||
@@ -127,16 +106,17 @@ namespace ErsatzTV.Core.Metadata
|
||||
}
|
||||
|
||||
private async Task<Unit> ScanEpisodes(
|
||||
LocalMediaSource localMediaSource,
|
||||
LibraryPath libraryPath,
|
||||
string ffprobePath,
|
||||
TelevisionSeason season)
|
||||
Season season,
|
||||
string seasonPath)
|
||||
{
|
||||
foreach (string file in _localFileSystem.ListFiles(season.Path)
|
||||
foreach (string file in _localFileSystem.ListFiles(seasonPath)
|
||||
.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)
|
||||
Either<BaseError, Episode> maybeEpisode = await _televisionRepository
|
||||
.GetOrAddEpisode(season, libraryPath, file)
|
||||
.BindT(episode => UpdateStatistics(episode, ffprobePath).MapT(_ => episode))
|
||||
.BindT(UpdateMetadata)
|
||||
.BindT(UpdateThumbnail);
|
||||
@@ -148,8 +128,8 @@ namespace ErsatzTV.Core.Metadata
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
private async Task<Either<BaseError, TelevisionShow>> UpdateMetadataForShow(
|
||||
TelevisionShow show,
|
||||
private async Task<Either<BaseError, Show>> UpdateMetadataForShow(
|
||||
Show show,
|
||||
string showFolder)
|
||||
{
|
||||
try
|
||||
@@ -157,9 +137,12 @@ namespace ErsatzTV.Core.Metadata
|
||||
await LocateNfoFileForShow(showFolder).Match(
|
||||
async nfoFile =>
|
||||
{
|
||||
if (show.Metadata == null || show.Metadata.Source == MetadataSource.Fallback ||
|
||||
(show.Metadata.LastWriteTime ?? DateTime.MinValue) <
|
||||
_localFileSystem.GetLastWriteTime(nfoFile))
|
||||
bool shouldUpdate = Optional(show.ShowMetadata).Flatten().HeadOrNone().Match(
|
||||
m => m.MetadataKind == MetadataKind.Fallback ||
|
||||
m.DateUpdated < _localFileSystem.GetLastWriteTime(nfoFile),
|
||||
true);
|
||||
|
||||
if (shouldUpdate)
|
||||
{
|
||||
_logger.LogDebug("Refreshing {Attribute} from {Path}", "Sidecar Metadata", nfoFile);
|
||||
await _localMetadataProvider.RefreshSidecarMetadata(show, nfoFile);
|
||||
@@ -167,7 +150,7 @@ namespace ErsatzTV.Core.Metadata
|
||||
},
|
||||
async () =>
|
||||
{
|
||||
if (show.Metadata == null)
|
||||
if (!Optional(show.ShowMetadata).Flatten().Any())
|
||||
{
|
||||
_logger.LogDebug("Refreshing {Attribute} for {Path}", "Fallback Metadata", showFolder);
|
||||
await _localMetadataProvider.RefreshFallbackMetadata(show, showFolder);
|
||||
@@ -182,17 +165,20 @@ namespace ErsatzTV.Core.Metadata
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<Either<BaseError, TelevisionEpisodeMediaItem>> UpdateMetadata(
|
||||
TelevisionEpisodeMediaItem episode)
|
||||
private async Task<Either<BaseError, Episode>> UpdateMetadata(
|
||||
Episode 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))
|
||||
bool shouldUpdate = Optional(episode.EpisodeMetadata).Flatten().HeadOrNone().Match(
|
||||
m => m.MetadataKind == MetadataKind.Fallback ||
|
||||
m.DateUpdated < _localFileSystem.GetLastWriteTime(nfoFile),
|
||||
true);
|
||||
|
||||
if (shouldUpdate)
|
||||
{
|
||||
_logger.LogDebug("Refreshing {Attribute} from {Path}", "Sidecar Metadata", nfoFile);
|
||||
await _localMetadataProvider.RefreshSidecarMetadata(episode, nfoFile);
|
||||
@@ -200,9 +186,10 @@ namespace ErsatzTV.Core.Metadata
|
||||
},
|
||||
async () =>
|
||||
{
|
||||
if (episode.Metadata == null)
|
||||
if (!Optional(episode.EpisodeMetadata).Flatten().Any())
|
||||
{
|
||||
_logger.LogDebug("Refreshing {Attribute} for {Path}", "Fallback Metadata", episode.Path);
|
||||
string path = episode.MediaVersions.Head().MediaFiles.Head().Path;
|
||||
_logger.LogDebug("Refreshing {Attribute} for {Path}", "Fallback Metadata", path);
|
||||
await _localMetadataProvider.RefreshFallbackMetadata(episode);
|
||||
}
|
||||
});
|
||||
@@ -215,8 +202,8 @@ namespace ErsatzTV.Core.Metadata
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<Either<BaseError, TelevisionShow>> UpdatePosterForShow(
|
||||
TelevisionShow show,
|
||||
private async Task<Either<BaseError, Show>> UpdatePosterForShow(
|
||||
Show show,
|
||||
string showFolder)
|
||||
{
|
||||
try
|
||||
@@ -224,27 +211,10 @@ namespace ErsatzTV.Core.Metadata
|
||||
await LocatePosterForShow(showFolder).IfSomeAsync(
|
||||
async posterFile =>
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(show.Poster) ||
|
||||
(show.PosterLastWriteTime ?? DateTime.MinValue) <
|
||||
_localFileSystem.GetLastWriteTime(posterFile))
|
||||
ShowMetadata metadata = show.ShowMetadata.Head();
|
||||
if (RefreshArtwork(posterFile, metadata, ArtworkKind.Poster))
|
||||
{
|
||||
_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;
|
||||
});
|
||||
await _televisionRepository.Update(show);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -256,19 +226,24 @@ namespace ErsatzTV.Core.Metadata
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<Either<BaseError, TelevisionSeason>> UpdatePoster(TelevisionSeason season)
|
||||
private async Task<Either<BaseError, Season>> UpdatePoster(Season season, string seasonFolder)
|
||||
{
|
||||
try
|
||||
{
|
||||
await LocatePoster(season).IfSomeAsync(
|
||||
await LocatePoster(season, seasonFolder).IfSomeAsync(
|
||||
async posterFile =>
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(season.Poster) ||
|
||||
(season.PosterLastWriteTime ?? DateTime.MinValue) <
|
||||
_localFileSystem.GetLastWriteTime(posterFile))
|
||||
season.SeasonMetadata ??= new List<SeasonMetadata>();
|
||||
if (!season.SeasonMetadata.Any())
|
||||
{
|
||||
_logger.LogDebug("Refreshing {Attribute} from {Path}", "Poster", posterFile);
|
||||
await SavePosterToDisk(season, posterFile, _televisionRepository.Update, 440);
|
||||
season.SeasonMetadata.Add(new SeasonMetadata { SeasonId = season.Id });
|
||||
}
|
||||
|
||||
SeasonMetadata metadata = season.SeasonMetadata.Head();
|
||||
|
||||
if (RefreshArtwork(posterFile, metadata, ArtworkKind.Poster))
|
||||
{
|
||||
await _televisionRepository.Update(season);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -280,20 +255,17 @@ namespace ErsatzTV.Core.Metadata
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<Either<BaseError, TelevisionEpisodeMediaItem>> UpdateThumbnail(
|
||||
TelevisionEpisodeMediaItem episode)
|
||||
private async Task<Either<BaseError, Episode>> UpdateThumbnail(Episode episode)
|
||||
{
|
||||
try
|
||||
{
|
||||
await LocateThumbnail(episode).IfSomeAsync(
|
||||
async posterFile =>
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(episode.Poster) ||
|
||||
(episode.PosterLastWriteTime ?? DateTime.MinValue) <
|
||||
_localFileSystem.GetLastWriteTime(posterFile))
|
||||
EpisodeMetadata metadata = episode.EpisodeMetadata.Head();
|
||||
if (RefreshArtwork(posterFile, metadata, ArtworkKind.Thumbnail))
|
||||
{
|
||||
_logger.LogDebug("Refreshing {Attribute} from {Path}", "Thumbnail", posterFile);
|
||||
await SavePosterToDisk(episode, posterFile, _televisionRepository.Update);
|
||||
await _televisionRepository.Update(episode);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -309,9 +281,12 @@ namespace ErsatzTV.Core.Metadata
|
||||
Optional(Path.Combine(showFolder, "tvshow.nfo"))
|
||||
.Filter(s => _localFileSystem.FileExists(s));
|
||||
|
||||
private Option<string> LocateNfoFile(TelevisionEpisodeMediaItem episode) =>
|
||||
Optional(Path.ChangeExtension(episode.Path, "nfo"))
|
||||
private Option<string> LocateNfoFile(Episode episode)
|
||||
{
|
||||
string path = episode.MediaVersions.Head().MediaFiles.Head().Path;
|
||||
return Optional(Path.ChangeExtension(path, "nfo"))
|
||||
.Filter(s => _localFileSystem.FileExists(s));
|
||||
}
|
||||
|
||||
private Option<string> LocatePosterForShow(string showFolder) =>
|
||||
ImageFileExtensions
|
||||
@@ -320,20 +295,21 @@ namespace ErsatzTV.Core.Metadata
|
||||
.Filter(s => _localFileSystem.FileExists(s))
|
||||
.HeadOrNone();
|
||||
|
||||
private Option<string> LocatePoster(TelevisionSeason season)
|
||||
private Option<string> LocatePoster(Season season, string seasonFolder)
|
||||
{
|
||||
string folder = Path.GetDirectoryName(season.Path) ?? string.Empty;
|
||||
string folder = Path.GetDirectoryName(seasonFolder) ?? string.Empty;
|
||||
return ImageFileExtensions
|
||||
.Map(ext => Path.Combine(folder, $"season{season.Number:00}-poster.{ext}"))
|
||||
.Map(ext => Path.Combine(folder, $"season{season.SeasonNumber:00}-poster.{ext}"))
|
||||
.Filter(s => _localFileSystem.FileExists(s))
|
||||
.HeadOrNone();
|
||||
}
|
||||
|
||||
private Option<string> LocateThumbnail(TelevisionEpisodeMediaItem episode)
|
||||
private Option<string> LocateThumbnail(Episode episode)
|
||||
{
|
||||
string folder = Path.GetDirectoryName(episode.Path) ?? string.Empty;
|
||||
string path = episode.MediaVersions.Head().MediaFiles.Head().Path;
|
||||
string folder = Path.GetDirectoryName(path) ?? string.Empty;
|
||||
return ImageFileExtensions
|
||||
.Map(ext => Path.GetFileNameWithoutExtension(episode.Path) + $"-thumb.{ext}")
|
||||
.Map(ext => Path.GetFileNameWithoutExtension(path) + $"-thumb.{ext}")
|
||||
.Map(f => Path.Combine(folder, f))
|
||||
.Filter(f => _localFileSystem.FileExists(f))
|
||||
.HeadOrNone();
|
||||
|
||||
Reference in New Issue
Block a user