improve scanning, add refresh button to media cards (#23)
* support .etvignore files to exclude folders (and child folders) from scanner * include top-level folder in scanner * don't always rescan "other" media sources * add metadata/poster refresh button to media cards
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
namespace ErsatzTV.Application.MediaItems
|
||||
{
|
||||
public record AggregateMediaItemViewModel(
|
||||
int MediaItemId,
|
||||
string Title,
|
||||
string Subtitle,
|
||||
string SortTitle,
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using LanguageExt;
|
||||
|
||||
namespace ErsatzTV.Application.MediaItems.Commands
|
||||
{
|
||||
public class
|
||||
RefreshMediaItemPosterHandler : MediatR.IRequestHandler<RefreshMediaItemPoster,
|
||||
Either<BaseError, Unit>>
|
||||
{
|
||||
private readonly ILocalPosterProvider _localPosterProvider;
|
||||
private readonly IMediaItemRepository _mediaItemRepository;
|
||||
|
||||
public RefreshMediaItemPosterHandler(
|
||||
IMediaItemRepository mediaItemRepository,
|
||||
ILocalPosterProvider localPosterProvider)
|
||||
{
|
||||
_mediaItemRepository = mediaItemRepository;
|
||||
_localPosterProvider = localPosterProvider;
|
||||
}
|
||||
|
||||
public Task<Either<BaseError, Unit>> Handle(
|
||||
RefreshMediaItemPoster request,
|
||||
CancellationToken cancellationToken) =>
|
||||
Validate(request)
|
||||
.MapT(RefreshPoster)
|
||||
.Bind(v => v.ToEitherAsync());
|
||||
|
||||
private Task<Validation<BaseError, MediaItem>> Validate(RefreshMediaItemPoster request) =>
|
||||
MediaItemMustExist(request);
|
||||
|
||||
private Task<Validation<BaseError, MediaItem>> MediaItemMustExist(RefreshMediaItemPoster request) =>
|
||||
_mediaItemRepository.Get(request.MediaItemId)
|
||||
.Map(
|
||||
maybeItem => maybeItem.ToValidation<BaseError>(
|
||||
$"[MediaItem] {request.MediaItemId} does not exist."));
|
||||
|
||||
private Task<Unit> RefreshPoster(MediaItem mediaItem) =>
|
||||
_localPosterProvider.RefreshPoster(mediaItem).ToUnit();
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,7 @@ namespace ErsatzTV.Application.MediaItems.Queries
|
||||
var results = allItems
|
||||
.Map(
|
||||
s => new AggregateMediaItemViewModel(
|
||||
s.MediaItemId,
|
||||
s.Title,
|
||||
s.Subtitle,
|
||||
s.SortTitle,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace ErsatzTV.Core.AggregateModels
|
||||
{
|
||||
public record MediaItemSummary(string Title, string SortTitle, string Subtitle, string Poster);
|
||||
public record MediaItemSummary(int MediaItemId, string Title, string SortTitle, string Subtitle, string Poster);
|
||||
}
|
||||
|
||||
@@ -66,8 +66,19 @@ namespace ErsatzTV.Core.Metadata
|
||||
".avi", ".wmv", ".mov", ".mkv", ".ts"
|
||||
};
|
||||
|
||||
var allFiles = Directory.GetFiles(localMediaSource.Folder, "*", SearchOption.AllDirectories)
|
||||
Seq<string> allDirectories = Directory
|
||||
.GetDirectories(localMediaSource.Folder, "*", SearchOption.AllDirectories)
|
||||
.ToSeq().Add(localMediaSource.Folder);
|
||||
|
||||
// remove any directories with an .etvignore file locally, or in any parent directory
|
||||
Seq<string> excluded = allDirectories.Filter(ShouldExcludeDirectory);
|
||||
Seq<string> relevantDirectories = allDirectories
|
||||
.Filter(d => !excluded.Any(d.StartsWith));
|
||||
|
||||
var allFiles = relevantDirectories
|
||||
.Collect(d => Directory.GetFiles(d, "*", SearchOption.TopDirectoryOnly))
|
||||
.Filter(file => knownExtensions.Contains(Path.GetExtension(file)))
|
||||
.OrderBy(identity)
|
||||
.ToSeq();
|
||||
|
||||
// check if the media item exists
|
||||
@@ -85,13 +96,14 @@ namespace ErsatzTV.Core.Metadata
|
||||
}
|
||||
|
||||
// if exists, check if the file was modified
|
||||
// also, try to re-categorize "other" by refreshing metadata
|
||||
// also, try to re-categorize incorrect media types by refreshing metadata
|
||||
Seq<MediaItem> modifiedMediaItems = existingMediaItems.Filter(
|
||||
mediaItem =>
|
||||
{
|
||||
DateTime lastWrite = File.GetLastWriteTimeUtc(mediaItem.Path);
|
||||
bool modified = lastWrite > mediaItem.LastWriteTime.IfNone(DateTime.MinValue);
|
||||
return modified || mediaItem.Metadata == null || mediaItem.Metadata.MediaType == MediaType.Other;
|
||||
return modified || mediaItem.Metadata == null ||
|
||||
mediaItem.Metadata.MediaType != localMediaSource.MediaType;
|
||||
});
|
||||
modifiedPlayoutIds.AddRange(await _playoutRepository.GetPlayoutIdsForMediaItems(modifiedMediaItems));
|
||||
foreach (MediaItem mediaItem in modifiedMediaItems)
|
||||
@@ -141,5 +153,7 @@ namespace ErsatzTV.Core.Metadata
|
||||
await _localPosterProvider.RefreshPoster(mediaItem);
|
||||
await _smartCollectionBuilder.RefreshSmartCollections(mediaItem);
|
||||
}
|
||||
|
||||
private static bool ShouldExcludeDirectory(string path) => File.Exists(Path.Combine(path, ".etvignore"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,7 +24,10 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
}
|
||||
|
||||
public Task<Option<MediaItem>> Get(int id) =>
|
||||
_dbContext.MediaItems.SingleOrDefaultAsync(i => i.Id == id).Map(Optional);
|
||||
_dbContext.MediaItems
|
||||
.Include(i => i.Source)
|
||||
.SingleOrDefaultAsync(i => i.Id == id)
|
||||
.Map(Optional);
|
||||
|
||||
public Task<List<MediaItem>> GetAll() => _dbContext.MediaItems.ToListAsync();
|
||||
|
||||
@@ -46,6 +49,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
{
|
||||
MediaType.Movie => _dbContext.MediaItemSummaries.FromSqlRaw(
|
||||
@"SELECT
|
||||
Id AS MediaItemId,
|
||||
Metadata_Title AS Title,
|
||||
Metadata_SortTitle AS SortTitle,
|
||||
substr(Metadata_Aired, 1, 4) AS Subtitle,
|
||||
@@ -59,10 +63,11 @@ LIMIT {0} OFFSET {1}",
|
||||
.ToListAsync(),
|
||||
MediaType.TvShow => _dbContext.MediaItemSummaries.FromSqlRaw(
|
||||
@"SELECT
|
||||
min(Id) AS MediaItemId,
|
||||
Metadata_Title AS Title,
|
||||
Metadata_SortTitle AS SortTitle,
|
||||
count(*) || ' Episodes' AS Subtitle,
|
||||
min(Poster) AS Poster
|
||||
max(Poster) AS Poster
|
||||
FROM MediaItems WHERE Metadata_MediaType=1
|
||||
GROUP BY Metadata_Title, Metadata_SortTitle
|
||||
ORDER BY Metadata_SortTitle
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
<s:Boolean x:Key="/Default/UserDictionary/Words/=discardcorrupt/@EntryIndexedValue">True</s:Boolean>
|
||||
<s:Boolean x:Key="/Default/UserDictionary/Words/=drawtext/@EntryIndexedValue">True</s:Boolean>
|
||||
<s:Boolean x:Key="/Default/UserDictionary/Words/=ersatztv/@EntryIndexedValue">True</s:Boolean>
|
||||
<s:Boolean x:Key="/Default/UserDictionary/Words/=etvignore/@EntryIndexedValue">True</s:Boolean>
|
||||
<s:Boolean x:Key="/Default/UserDictionary/Words/=faststart/@EntryIndexedValue">True</s:Boolean>
|
||||
<s:Boolean x:Key="/Default/UserDictionary/Words/=ffconcat/@EntryIndexedValue">True</s:Boolean>
|
||||
<s:Boolean x:Key="/Default/UserDictionary/Words/=fflags/@EntryIndexedValue">True</s:Boolean>
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="MudBlazor" Version="5.0.0" />
|
||||
<PackageReference Include="MudBlazor" Version="5.0.1" />
|
||||
<PackageReference Include="Refit.HttpClientFactory" Version="6.0.1" />
|
||||
<PackageReference Include="Serilog" Version="2.10.0" />
|
||||
<PackageReference Include="Serilog.AspNetCore" Version="3.4.0" />
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
@using ErsatzTV.Application.MediaItems
|
||||
@using ErsatzTV.Application.MediaItems.Commands
|
||||
@using Unit = LanguageExt.Unit
|
||||
@inject IMediator Mediator
|
||||
|
||||
<div class="media-card-container mx-3 pb-3">
|
||||
<MudPaper Class="media-card">
|
||||
@if (!string.IsNullOrWhiteSpace(Data.Poster))
|
||||
{
|
||||
<img src="@($"/posters/{Data.Poster}")" style="max-height: 220px"/>
|
||||
}
|
||||
else
|
||||
<MudPaper Class="media-card" Style="@PosterForItem()">
|
||||
@if (string.IsNullOrWhiteSpace(Data.Poster))
|
||||
{
|
||||
<MudText Align="Align.Center" Typo="Typo.h1" Class="media-card-poster-placeholder mud-text-disabled">
|
||||
@Placeholder(Data.SortTitle)
|
||||
</MudText>
|
||||
}
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Refresh" Color="Color.Primary" OnClick="@(() => RefreshMetadata())" Class="media-card-menu"></MudIconButton>
|
||||
</MudPaper>
|
||||
<MudText Align="Align.Center" Class="media-card-title" UserAttributes="@(new Dictionary<string, object> { { "title", Data.Title } })">
|
||||
@Data.Title
|
||||
@@ -25,10 +26,26 @@
|
||||
[Parameter]
|
||||
public AggregateMediaItemViewModel Data { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public EventCallback<Unit> DataRefreshed { get; set; }
|
||||
|
||||
private string Placeholder(string sortTitle)
|
||||
{
|
||||
string first = sortTitle.Substring(0, 1).ToUpperInvariant();
|
||||
return int.TryParse(first, out _) ? "#" : first;
|
||||
}
|
||||
|
||||
private string PosterForItem() => string.IsNullOrWhiteSpace(Data.Poster)
|
||||
? "position: relative"
|
||||
: $"position: relative; background-image: url(/posters/{Data.Poster}); background-size: cover";
|
||||
|
||||
private async Task RefreshMetadata()
|
||||
{
|
||||
// TODO: how should we refresh an entire television show?
|
||||
await Mediator.Send(new RefreshMediaItemMetadata(Data.MediaItemId));
|
||||
await Mediator.Send(new RefreshMediaItemCollections(Data.MediaItemId));
|
||||
await Mediator.Send(new RefreshMediaItemPoster(Data.MediaItemId));
|
||||
await DataRefreshed.InvokeAsync();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -21,7 +21,7 @@
|
||||
<MudContainer MaxWidth="MaxWidth.False" Class="media-card-grid">
|
||||
@foreach (AggregateMediaItemViewModel item in _data.DataPage)
|
||||
{
|
||||
<MediaCard Data="@item"/>
|
||||
<MediaCard Data="@item" DataRefreshed="@(() => RefreshData())"/>
|
||||
}
|
||||
</MudContainer>
|
||||
|
||||
|
||||
@@ -8,12 +8,16 @@
|
||||
|
||||
.media-card {
|
||||
display: flex;
|
||||
filter: brightness(100%);
|
||||
flex-direction: column;
|
||||
height: 220px;
|
||||
justify-content: center;
|
||||
transition: all 0.2s ease;
|
||||
width: 152px;
|
||||
}
|
||||
|
||||
.media-card:hover { filter: brightness(80%); }
|
||||
|
||||
.media-card-title {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
@@ -21,4 +25,13 @@
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.media-card-poster-placeholder { font-weight: bold; }
|
||||
.media-card-poster-placeholder { font-weight: bold; }
|
||||
|
||||
.media-card-menu {
|
||||
bottom: 0;
|
||||
display: none;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
.media-card:hover .media-card-menu { display: block; }
|
||||
Reference in New Issue
Block a user