diff --git a/ErsatzTV.Application/Television/Mapper.cs b/ErsatzTV.Application/Television/Mapper.cs index df4fcca53..f4e7b3bc6 100644 --- a/ErsatzTV.Application/Television/Mapper.cs +++ b/ErsatzTV.Application/Television/Mapper.cs @@ -1,4 +1,6 @@ -using ErsatzTV.Core.Domain; +using System.Linq; +using ErsatzTV.Core.Domain; +using static LanguageExt.Prelude; namespace ErsatzTV.Application.Television { @@ -10,7 +12,7 @@ namespace ErsatzTV.Application.Television show.ShowMetadata.HeadOrNone().Map(m => m.Title).IfNone(string.Empty), show.ShowMetadata.HeadOrNone().Map(m => m.ReleaseDate?.Year.ToString()).IfNone(string.Empty), show.ShowMetadata.HeadOrNone().Map(m => m.Plot).IfNone(string.Empty), - null); // TODO: artwork + show.ShowMetadata.HeadOrNone().Map(GetPoster).IfNone(string.Empty)); internal static TelevisionSeasonViewModel ProjectToViewModel(Season season) => new( @@ -19,7 +21,7 @@ namespace ErsatzTV.Application.Television season.Show.ShowMetadata.HeadOrNone().Map(m => m.Title).IfNone(string.Empty), season.Show.ShowMetadata.HeadOrNone().Map(m => m.ReleaseDate?.Year.ToString()).IfNone(string.Empty), season.SeasonNumber == 0 ? "Specials" : $"Season {season.SeasonNumber}", - null); // TODO: artwork + season.SeasonMetadata.HeadOrNone().Map(GetPoster).IfNone(string.Empty)); internal static TelevisionEpisodeViewModel ProjectToViewModel(Episode episode) => new( @@ -28,6 +30,14 @@ namespace ErsatzTV.Application.Television episode.EpisodeNumber, episode.EpisodeMetadata.HeadOrNone().Map(m => m.Title).IfNone(string.Empty), episode.EpisodeMetadata.HeadOrNone().Map(m => m.Plot).IfNone(string.Empty), - null); // TODO: artwork + episode.EpisodeMetadata.HeadOrNone().Map(GetThumbnail).IfNone(string.Empty)); + + private static string GetPoster(Metadata metadata) => + Optional(metadata.Artwork.FirstOrDefault(a => a.ArtworkKind == ArtworkKind.Poster)) + .Match(a => a.Path, string.Empty); + + private static string GetThumbnail(Metadata metadata) => + Optional(metadata.Artwork.FirstOrDefault(a => a.ArtworkKind == ArtworkKind.Thumbnail)) + .Match(a => a.Path, string.Empty); } } diff --git a/ErsatzTV.Core.Tests/Fakes/FakeTelevisionRepository.cs b/ErsatzTV.Core.Tests/Fakes/FakeTelevisionRepository.cs index e2bfda3d3..c33fb1eae 100644 --- a/ErsatzTV.Core.Tests/Fakes/FakeTelevisionRepository.cs +++ b/ErsatzTV.Core.Tests/Fakes/FakeTelevisionRepository.cs @@ -44,10 +44,11 @@ namespace ErsatzTV.Core.Tests.Fakes public Task> GetPagedEpisodes(int seasonId, int pageNumber, int pageSize) => throw new NotSupportedException(); - public Task> GetShowByMetadata(ShowMetadata metadata) => throw new NotSupportedException(); + public Task> GetShowByMetadata(int libraryPathId, ShowMetadata metadata) => + throw new NotSupportedException(); public Task> - AddShow(int localMediaSourceId, string showFolder, ShowMetadata metadata) => + AddShow(int libraryPathId, string showFolder, ShowMetadata metadata) => throw new NotSupportedException(); public Task> GetOrAddSeason(Show show, string path, int seasonNumber) => diff --git a/ErsatzTV.Core/Interfaces/Repositories/ITelevisionRepository.cs b/ErsatzTV.Core/Interfaces/Repositories/ITelevisionRepository.cs index 041d59d93..10fda8992 100644 --- a/ErsatzTV.Core/Interfaces/Repositories/ITelevisionRepository.cs +++ b/ErsatzTV.Core/Interfaces/Repositories/ITelevisionRepository.cs @@ -23,10 +23,10 @@ namespace ErsatzTV.Core.Interfaces.Repositories Task> GetEpisode(int televisionEpisodeId); Task GetEpisodeCount(int televisionSeasonId); Task> GetPagedEpisodes(int seasonId, int pageNumber, int pageSize); - Task> GetShowByMetadata(ShowMetadata metadata); + Task> GetShowByMetadata(int libraryPathId, ShowMetadata metadata); Task> AddShow( - int localMediaSourceId, + int libraryPathId, string showFolder, ShowMetadata metadata); diff --git a/ErsatzTV.Core/Metadata/LocalFolderScanner.cs b/ErsatzTV.Core/Metadata/LocalFolderScanner.cs index 6dbbd8979..7c6eb9f0d 100644 --- a/ErsatzTV.Core/Metadata/LocalFolderScanner.cs +++ b/ErsatzTV.Core/Metadata/LocalFolderScanner.cs @@ -88,6 +88,8 @@ namespace ErsatzTV.Core.Metadata { DateTime lastWriteTime = _localFileSystem.GetLastWriteTime(artworkFile); + metadata.Artwork ??= new List(); + Option maybePoster = metadata.Artwork.FirstOrDefault(a => a.ArtworkKind == artworkKind); diff --git a/ErsatzTV.Core/Metadata/LocalMetadataProvider.cs b/ErsatzTV.Core/Metadata/LocalMetadataProvider.cs index 72a8d4db8..1a5473b1a 100644 --- a/ErsatzTV.Core/Metadata/LocalMetadataProvider.cs +++ b/ErsatzTV.Core/Metadata/LocalMetadataProvider.cs @@ -38,19 +38,22 @@ namespace ErsatzTV.Core.Metadata _logger = logger; } - public Task GetMetadataForShow(string showFolder) + public async Task 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 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 RefreshSidecarMetadata(MediaItem mediaItem, string path) => diff --git a/ErsatzTV.Core/Metadata/TelevisionFolderScanner.cs b/ErsatzTV.Core/Metadata/TelevisionFolderScanner.cs index a9e410e7d..c80c6312e 100644 --- a/ErsatzTV.Core/Metadata/TelevisionFolderScanner.cs +++ b/ErsatzTV.Core/Metadata/TelevisionFolderScanner.cs @@ -76,7 +76,7 @@ namespace ErsatzTV.Core.Metadata string showFolder) { ShowMetadata metadata = await _localMetadataProvider.GetMetadataForShow(showFolder); - Option maybeShow = await _televisionRepository.GetShowByMetadata(metadata); + Option maybeShow = await _televisionRepository.GetShowByMetadata(libraryPathId, metadata); return await maybeShow.Match( show => Right(show).AsTask(), async () => await _televisionRepository.AddShow(libraryPathId, showFolder, metadata)); @@ -86,9 +86,9 @@ namespace ErsatzTV.Core.Metadata LibraryPath libraryPath, string ffprobePath, Show show, - string showPath) + string showFolder) { - foreach (string seasonFolder in _localFileSystem.ListSubdirectories(showPath).Filter(ShouldIncludeFolder) + foreach (string seasonFolder in _localFileSystem.ListSubdirectories(showFolder).Filter(ShouldIncludeFolder) .OrderBy(identity)) { Option maybeSeasonNumber = SeasonNumberForFolder(seasonFolder); @@ -97,10 +97,10 @@ namespace ErsatzTV.Core.Metadata { Either maybeSeason = await _televisionRepository .GetOrAddSeason(show, seasonFolder, seasonNumber) - .BindT(UpdatePoster); + .BindT(season => UpdatePoster(season, seasonFolder)); await maybeSeason.Match( - season => ScanEpisodes(libraryPath, ffprobePath, season), + season => ScanEpisodes(libraryPath, ffprobePath, season, seasonFolder), _ => Task.FromResult(Unit.Default)); }); } @@ -111,9 +111,10 @@ namespace ErsatzTV.Core.Metadata private async Task ScanEpisodes( LibraryPath libraryPath, string ffprobePath, - Season 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 @@ -227,11 +228,11 @@ namespace ErsatzTV.Core.Metadata } } - private async Task> UpdatePoster(Season season) + private async Task> UpdatePoster(Season season, string seasonFolder) { try { - await LocatePoster(season).IfSomeAsync( + await LocatePoster(season, seasonFolder).IfSomeAsync( async posterFile => { season.SeasonMetadata ??= new List(); @@ -293,9 +294,9 @@ namespace ErsatzTV.Core.Metadata .Filter(s => _localFileSystem.FileExists(s)) .HeadOrNone(); - private Option LocatePoster(Season season) + private Option 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.SeasonNumber:00}-poster.{ext}")) .Filter(s => _localFileSystem.FileExists(s)) diff --git a/ErsatzTV.Infrastructure/Data/Repositories/MovieRepository.cs b/ErsatzTV.Infrastructure/Data/Repositories/MovieRepository.cs index cbd11c25a..d887ed358 100644 --- a/ErsatzTV.Infrastructure/Data/Repositories/MovieRepository.cs +++ b/ErsatzTV.Infrastructure/Data/Repositories/MovieRepository.cs @@ -17,19 +17,27 @@ namespace ErsatzTV.Infrastructure.Data.Repositories { private readonly IDbConnection _dbConnection; private readonly TvContext _dbContext; + private readonly IDbContextFactory _dbContextFactory; - public MovieRepository(TvContext dbContext, IDbConnection dbConnection) + public MovieRepository( + TvContext dbContext, + IDbContextFactory dbContextFactory, + IDbConnection dbConnection) { _dbContext = dbContext; + _dbContextFactory = dbContextFactory; _dbConnection = dbConnection; } - public Task> GetMovie(int movieId) => - _dbContext.Movies + public async Task> GetMovie(int movieId) + { + await using TvContext dbContext = _dbContextFactory.CreateDbContext(); + return await dbContext.Movies .Include(m => m.MovieMetadata) .ThenInclude(m => m.Artwork) .SingleOrDefaultAsync(m => m.Id == movieId) .Map(Optional); + } public async Task> GetOrAdd(LibraryPath libraryPath, string path) { diff --git a/ErsatzTV.Infrastructure/Data/Repositories/TelevisionRepository.cs b/ErsatzTV.Infrastructure/Data/Repositories/TelevisionRepository.cs index ec98a1414..4c5d542e4 100644 --- a/ErsatzTV.Infrastructure/Data/Repositories/TelevisionRepository.cs +++ b/ErsatzTV.Infrastructure/Data/Repositories/TelevisionRepository.cs @@ -53,6 +53,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories .AsNoTracking() .Filter(s => s.Id == televisionShowId) .Include(s => s.ShowMetadata) + .ThenInclude(sm => sm.Artwork) .SingleOrDefaultAsync() .Map(Optional); @@ -84,6 +85,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories public Task> GetSeason(int televisionSeasonId) => _dbContext.Seasons .AsNoTracking() + .Include(s => s.SeasonMetadata) + .ThenInclude(sm => sm.Artwork) .Include(s => s.Show) .ThenInclude(s => s.ShowMetadata) .SingleOrDefaultAsync(s => s.Id == televisionSeasonId) @@ -123,6 +126,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories .AsNoTracking() .Include(e => e.Season) .Include(e => e.EpisodeMetadata) + .ThenInclude(em => em.Artwork) .SingleOrDefaultAsync(s => s.Id == televisionEpisodeId) .Map(Optional); @@ -146,13 +150,14 @@ namespace ErsatzTV.Infrastructure.Data.Repositories .Take(pageSize) .ToListAsync(); - public async Task> GetShowByMetadata(ShowMetadata metadata) + public async Task> GetShowByMetadata(int libraryPathId, ShowMetadata metadata) { Option maybeId = await _dbContext.ShowMetadata .Where(s => s.Title == metadata.Title && s.ReleaseDate == metadata.ReleaseDate) + .Where(s => s.Show.LibraryPathId == libraryPathId) .SingleOrDefaultAsync() - .Map(sm => sm.ShowId) - .Map(Optional); + .Map(Optional) + .MapT(sm => sm.ShowId); return await maybeId.Match( id => @@ -160,22 +165,20 @@ namespace ErsatzTV.Infrastructure.Data.Repositories return _dbContext.Shows .Include(s => s.ShowMetadata) .ThenInclude(sm => sm.Artwork) - .Filter(s => s.Id == id) - .SingleOrDefaultAsync() + .OrderBy(s => s.Id) + .SingleOrDefaultAsync(s => s.Id == id) .Map(Optional); }, () => Option.None.AsTask()); } - public async Task> AddShow( - int localMediaSourceId, - string showFolder, - ShowMetadata metadata) + public async Task> AddShow(int libraryPathId, string showFolder, ShowMetadata metadata) { try { var show = new Show { + LibraryPathId = libraryPathId, ShowMetadata = new List { metadata }, Seasons = new List() }; @@ -191,27 +194,23 @@ namespace ErsatzTV.Infrastructure.Data.Repositories } } - public async Task> GetOrAddSeason( - Show show, - string path, - int seasonNumber) + public async Task> GetOrAddSeason(Show show, string path, int seasonNumber) { Option maybeExisting = await _dbContext.Seasons - .SingleOrDefaultAsync(i => i.Path == path); + .Include(s => s.SeasonMetadata) + .ThenInclude(sm => sm.Artwork) + .SingleOrDefaultAsync(s => s.ShowId == show.Id && s.SeasonNumber == seasonNumber); return await maybeExisting.Match( season => Right(season).AsTask(), () => AddSeason(show, path, seasonNumber)); } - // TODO: don't use mediaSourceId, use library path id - public async Task> GetOrAddEpisode( - Season season, - LibraryPath libraryPath, - string path) + public async Task> GetOrAddEpisode(Season season, LibraryPath libraryPath, string path) { Option maybeExisting = await _dbContext.Episodes .Include(i => i.EpisodeMetadata) + .ThenInclude(em => em.Artwork) .Include(i => i.Statistics) .SingleOrDefaultAsync(i => i.Path == path); @@ -253,17 +252,17 @@ namespace ErsatzTV.Infrastructure.Data.Repositories .Filter(e => e.SeasonId == seasonId) .ToListAsync(); - private async Task> AddSeason( - Show show, - string path, - int seasonNumber) + private async Task> AddSeason(Show show, string path, int seasonNumber) { try { var season = new Season { - ShowId = show.Id, Path = path, SeasonNumber = seasonNumber, - Episodes = new List() + ShowId = show.Id, + Path = path, + SeasonNumber = seasonNumber, + Episodes = new List(), + SeasonMetadata = new List() }; await _dbContext.Seasons.AddAsync(season); await _dbContext.SaveChangesAsync(); @@ -275,18 +274,16 @@ namespace ErsatzTV.Infrastructure.Data.Repositories } } - private async Task> AddEpisode( - Season season, - int mediaSourceId, - string path) + private async Task> AddEpisode(Season season, int libraryPathId, string path) { try { var episode = new Episode { - LibraryPathId = mediaSourceId, + LibraryPathId = libraryPathId, SeasonId = season.Id, - Path = path + Path = path, + EpisodeMetadata = new List() }; await _dbContext.Episodes.AddAsync(episode); await _dbContext.SaveChangesAsync(); diff --git a/ErsatzTV/Controllers/PostersController.cs b/ErsatzTV/Controllers/ArtworkController.cs similarity index 58% rename from ErsatzTV/Controllers/PostersController.cs rename to ErsatzTV/Controllers/ArtworkController.cs index 4737881f3..b70d2e001 100644 --- a/ErsatzTV/Controllers/PostersController.cs +++ b/ErsatzTV/Controllers/ArtworkController.cs @@ -17,8 +17,8 @@ namespace ErsatzTV.Controllers public PostersController(IMediator mediator) => _mediator = mediator; - [HttpGet("/posters/{fileName}")] - public async Task GetImage(string fileName) + [HttpGet("/artwork/posters/{fileName}")] + public async Task GetPoster(string fileName) { Either imageContents = await _mediator.Send(new GetImageContents(fileName, ArtworkKind.Poster, 440)); @@ -26,5 +26,15 @@ namespace ErsatzTV.Controllers Left: _ => new NotFoundResult(), Right: r => new FileContentResult(r.Contents, r.MimeType)); } + + [HttpGet("/artwork/thumbnails/{fileName}")] + public async Task GetThumbnail(string fileName) + { + Either imageContents = + await _mediator.Send(new GetImageContents(fileName, ArtworkKind.Thumbnail, 220)); + return imageContents.Match( + Left: _ => new NotFoundResult(), + Right: r => new FileContentResult(r.Contents, r.MimeType)); + } } } diff --git a/ErsatzTV/Pages/Movie.razor b/ErsatzTV/Pages/Movie.razor index 77c58be69..fad754cc1 100644 --- a/ErsatzTV/Pages/Movie.razor +++ b/ErsatzTV/Pages/Movie.razor @@ -14,7 +14,7 @@ @if (!string.IsNullOrWhiteSpace(_movie.Poster)) { - + } diff --git a/ErsatzTV/Pages/TelevisionEpisode.razor b/ErsatzTV/Pages/TelevisionEpisode.razor index 5ad2d52d4..6bf39078e 100644 --- a/ErsatzTV/Pages/TelevisionEpisode.razor +++ b/ErsatzTV/Pages/TelevisionEpisode.razor @@ -14,7 +14,7 @@ @if (!string.IsNullOrWhiteSpace(_episode.Poster)) { - + } diff --git a/ErsatzTV/Pages/TelevisionEpisodeList.razor b/ErsatzTV/Pages/TelevisionEpisodeList.razor index 293b0400b..6ef6cab04 100644 --- a/ErsatzTV/Pages/TelevisionEpisodeList.razor +++ b/ErsatzTV/Pages/TelevisionEpisodeList.razor @@ -18,7 +18,7 @@ @if (!string.IsNullOrWhiteSpace(_season.Poster)) { - + } @@ -55,7 +55,8 @@ Link="@($"/media/tv/episodes/{card.EpisodeId}")" DataRefreshed="@(() => RefreshData())" ContainerClass="media-card-episode-container mx-2" - CardClass="media-card-episode"/> + CardClass="media-card-episode" + ArtworkKind="@ArtworkKind.Thumbnail"/> } diff --git a/ErsatzTV/Pages/TelevisionSeasonList.razor b/ErsatzTV/Pages/TelevisionSeasonList.razor index acf0e9c97..4701d4551 100644 --- a/ErsatzTV/Pages/TelevisionSeasonList.razor +++ b/ErsatzTV/Pages/TelevisionSeasonList.razor @@ -18,7 +18,7 @@ @if (!string.IsNullOrWhiteSpace(_show.Poster)) { - + } diff --git a/ErsatzTV/Shared/MediaCard.razor b/ErsatzTV/Shared/MediaCard.razor index 30d598686..58f0fe016 100644 --- a/ErsatzTV/Shared/MediaCard.razor +++ b/ErsatzTV/Shared/MediaCard.razor @@ -6,7 +6,7 @@ @if (!string.IsNullOrWhiteSpace(Link)) {
- + @if (string.IsNullOrWhiteSpace(Data.Poster)) { @@ -29,7 +29,7 @@ } else { - + @if (string.IsNullOrWhiteSpace(Data.Poster)) { @@ -72,6 +72,9 @@ [Parameter] public string CardClass { get; set; } + [Parameter] + public ArtworkKind ArtworkKind { get; set; } + [Parameter] public EventCallback DeleteClicked { get; set; } @@ -86,8 +89,14 @@ return int.TryParse(first, out _) ? "#" : first; } - private string PosterForItem() => string.IsNullOrWhiteSpace(Data.Poster) + private string ArtworkForItem() => string.IsNullOrWhiteSpace(Data.Poster) ? "position: relative" - : $"position: relative; background-image: url(/posters/{Data.Poster}); background-size: cover"; + : $"position: relative; background-image: url(/artwork/{PathForArtwork()}/{Data.Poster}); background-size: cover"; + + private string PathForArtwork() => ArtworkKind switch + { + ArtworkKind.Thumbnail => "thumbnails", + _ => "posters" + }; } \ No newline at end of file