Browse Source

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
pull/27/head v0.0.7-prealpha
Jason Dove 5 years ago committed by GitHub
parent
commit
2c9d4d796a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 1
      ErsatzTV.Application/MediaItems/AggregateMediaItemViewModel.cs
  2. 45
      ErsatzTV.Application/MediaItems/Commands/RefreshMediaItemPosterHandler.cs
  3. 1
      ErsatzTV.Application/MediaItems/Queries/GetAggregateMediaItemsHandler.cs
  4. 2
      ErsatzTV.Core/AggregateModels/MediaItemSummary.cs
  5. 20
      ErsatzTV.Core/Metadata/LocalMediaScanner.cs
  6. 9
      ErsatzTV.Infrastructure/Data/Repositories/MediaItemRepository.cs
  7. 1
      ErsatzTV.sln.DotSettings
  8. 2
      ErsatzTV/ErsatzTV.csproj
  9. 29
      ErsatzTV/Shared/MediaCard.razor
  10. 2
      ErsatzTV/Shared/MediaItemsGrid.razor
  11. 15
      ErsatzTV/wwwroot/css/site.css

1
ErsatzTV.Application/MediaItems/AggregateMediaItemViewModel.cs

@ -1,6 +1,7 @@
namespace ErsatzTV.Application.MediaItems namespace ErsatzTV.Application.MediaItems
{ {
public record AggregateMediaItemViewModel( public record AggregateMediaItemViewModel(
int MediaItemId,
string Title, string Title,
string Subtitle, string Subtitle,
string SortTitle, string SortTitle,

45
ErsatzTV.Application/MediaItems/Commands/RefreshMediaItemPosterHandler.cs

@ -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();
}
}

1
ErsatzTV.Application/MediaItems/Queries/GetAggregateMediaItemsHandler.cs

@ -30,6 +30,7 @@ namespace ErsatzTV.Application.MediaItems.Queries
var results = allItems var results = allItems
.Map( .Map(
s => new AggregateMediaItemViewModel( s => new AggregateMediaItemViewModel(
s.MediaItemId,
s.Title, s.Title,
s.Subtitle, s.Subtitle,
s.SortTitle, s.SortTitle,

2
ErsatzTV.Core/AggregateModels/MediaItemSummary.cs

@ -1,4 +1,4 @@
namespace ErsatzTV.Core.AggregateModels 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);
} }

20
ErsatzTV.Core/Metadata/LocalMediaScanner.cs

@ -66,8 +66,19 @@ namespace ErsatzTV.Core.Metadata
".avi", ".wmv", ".mov", ".mkv", ".ts" ".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))) .Filter(file => knownExtensions.Contains(Path.GetExtension(file)))
.OrderBy(identity)
.ToSeq(); .ToSeq();
// check if the media item exists // check if the media item exists
@ -85,13 +96,14 @@ namespace ErsatzTV.Core.Metadata
} }
// if exists, check if the file was modified // 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( Seq<MediaItem> modifiedMediaItems = existingMediaItems.Filter(
mediaItem => mediaItem =>
{ {
DateTime lastWrite = File.GetLastWriteTimeUtc(mediaItem.Path); DateTime lastWrite = File.GetLastWriteTimeUtc(mediaItem.Path);
bool modified = lastWrite > mediaItem.LastWriteTime.IfNone(DateTime.MinValue); 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)); modifiedPlayoutIds.AddRange(await _playoutRepository.GetPlayoutIdsForMediaItems(modifiedMediaItems));
foreach (MediaItem mediaItem in modifiedMediaItems) foreach (MediaItem mediaItem in modifiedMediaItems)
@ -141,5 +153,7 @@ namespace ErsatzTV.Core.Metadata
await _localPosterProvider.RefreshPoster(mediaItem); await _localPosterProvider.RefreshPoster(mediaItem);
await _smartCollectionBuilder.RefreshSmartCollections(mediaItem); await _smartCollectionBuilder.RefreshSmartCollections(mediaItem);
} }
private static bool ShouldExcludeDirectory(string path) => File.Exists(Path.Combine(path, ".etvignore"));
} }
} }

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

@ -24,7 +24,10 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
} }
public Task<Option<MediaItem>> Get(int id) => 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(); public Task<List<MediaItem>> GetAll() => _dbContext.MediaItems.ToListAsync();
@ -46,6 +49,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
{ {
MediaType.Movie => _dbContext.MediaItemSummaries.FromSqlRaw( MediaType.Movie => _dbContext.MediaItemSummaries.FromSqlRaw(
@"SELECT @"SELECT
Id AS MediaItemId,
Metadata_Title AS Title, Metadata_Title AS Title,
Metadata_SortTitle AS SortTitle, Metadata_SortTitle AS SortTitle,
substr(Metadata_Aired, 1, 4) AS Subtitle, substr(Metadata_Aired, 1, 4) AS Subtitle,
@ -59,10 +63,11 @@ LIMIT {0} OFFSET {1}",
.ToListAsync(), .ToListAsync(),
MediaType.TvShow => _dbContext.MediaItemSummaries.FromSqlRaw( MediaType.TvShow => _dbContext.MediaItemSummaries.FromSqlRaw(
@"SELECT @"SELECT
min(Id) AS MediaItemId,
Metadata_Title AS Title, Metadata_Title AS Title,
Metadata_SortTitle AS SortTitle, Metadata_SortTitle AS SortTitle,
count(*) || ' Episodes' AS Subtitle, count(*) || ' Episodes' AS Subtitle,
min(Poster) AS Poster max(Poster) AS Poster
FROM MediaItems WHERE Metadata_MediaType=1 FROM MediaItems WHERE Metadata_MediaType=1
GROUP BY Metadata_Title, Metadata_SortTitle GROUP BY Metadata_Title, Metadata_SortTitle
ORDER BY Metadata_SortTitle ORDER BY Metadata_SortTitle

1
ErsatzTV.sln.DotSettings

@ -11,6 +11,7 @@
<s:Boolean x:Key="/Default/UserDictionary/Words/=discardcorrupt/@EntryIndexedValue">True</s:Boolean> <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/=drawtext/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=ersatztv/@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/=faststart/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=ffconcat/@EntryIndexedValue">True</s:Boolean> <s:Boolean x:Key="/Default/UserDictionary/Words/=ffconcat/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=fflags/@EntryIndexedValue">True</s:Boolean> <s:Boolean x:Key="/Default/UserDictionary/Words/=fflags/@EntryIndexedValue">True</s:Boolean>

2
ErsatzTV/ErsatzTV.csproj

@ -20,7 +20,7 @@
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference> </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="Refit.HttpClientFactory" Version="6.0.1" />
<PackageReference Include="Serilog" Version="2.10.0" /> <PackageReference Include="Serilog" Version="2.10.0" />
<PackageReference Include="Serilog.AspNetCore" Version="3.4.0" /> <PackageReference Include="Serilog.AspNetCore" Version="3.4.0" />

29
ErsatzTV/Shared/MediaCard.razor

@ -1,16 +1,17 @@
@using ErsatzTV.Application.MediaItems @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"> <div class="media-card-container mx-3 pb-3">
<MudPaper Class="media-card"> <MudPaper Class="media-card" Style="@PosterForItem()">
@if (!string.IsNullOrWhiteSpace(Data.Poster)) @if (string.IsNullOrWhiteSpace(Data.Poster))
{
<img src="@($"/posters/{Data.Poster}")" style="max-height: 220px"/>
}
else
{ {
<MudText Align="Align.Center" Typo="Typo.h1" Class="media-card-poster-placeholder mud-text-disabled"> <MudText Align="Align.Center" Typo="Typo.h1" Class="media-card-poster-placeholder mud-text-disabled">
@Placeholder(Data.SortTitle) @Placeholder(Data.SortTitle)
</MudText> </MudText>
} }
<MudIconButton Icon="@Icons.Material.Filled.Refresh" Color="Color.Primary" OnClick="@(() => RefreshMetadata())" Class="media-card-menu"></MudIconButton>
</MudPaper> </MudPaper>
<MudText Align="Align.Center" Class="media-card-title" UserAttributes="@(new Dictionary<string, object> { { "title", Data.Title } })"> <MudText Align="Align.Center" Class="media-card-title" UserAttributes="@(new Dictionary<string, object> { { "title", Data.Title } })">
@Data.Title @Data.Title
@ -25,10 +26,26 @@
[Parameter] [Parameter]
public AggregateMediaItemViewModel Data { get; set; } public AggregateMediaItemViewModel Data { get; set; }
[Parameter]
public EventCallback<Unit> DataRefreshed { get; set; }
private string Placeholder(string sortTitle) private string Placeholder(string sortTitle)
{ {
string first = sortTitle.Substring(0, 1).ToUpperInvariant(); string first = sortTitle.Substring(0, 1).ToUpperInvariant();
return int.TryParse(first, out _) ? "#" : first; 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();
}
} }

2
ErsatzTV/Shared/MediaItemsGrid.razor

@ -21,7 +21,7 @@
<MudContainer MaxWidth="MaxWidth.False" Class="media-card-grid"> <MudContainer MaxWidth="MaxWidth.False" Class="media-card-grid">
@foreach (AggregateMediaItemViewModel item in _data.DataPage) @foreach (AggregateMediaItemViewModel item in _data.DataPage)
{ {
<MediaCard Data="@item"/> <MediaCard Data="@item" DataRefreshed="@(() => RefreshData())"/>
} }
</MudContainer> </MudContainer>

15
ErsatzTV/wwwroot/css/site.css

@ -8,12 +8,16 @@
.media-card { .media-card {
display: flex; display: flex;
filter: brightness(100%);
flex-direction: column; flex-direction: column;
height: 220px; height: 220px;
justify-content: center; justify-content: center;
transition: all 0.2s ease;
width: 152px; width: 152px;
} }
.media-card:hover { filter: brightness(80%); }
.media-card-title { .media-card-title {
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
@ -21,4 +25,13 @@
width: 100%; 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; }
Loading…
Cancel
Save