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 4 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 @@ @@ -1,6 +1,7 @@
namespace ErsatzTV.Application.MediaItems
{
public record AggregateMediaItemViewModel(
int MediaItemId,
string Title,
string Subtitle,
string SortTitle,

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

@ -0,0 +1,45 @@ @@ -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 @@ -30,6 +30,7 @@ namespace ErsatzTV.Application.MediaItems.Queries
var results = allItems
.Map(
s => new AggregateMediaItemViewModel(
s.MediaItemId,
s.Title,
s.Subtitle,
s.SortTitle,

2
ErsatzTV.Core/AggregateModels/MediaItemSummary.cs

@ -1,4 +1,4 @@ @@ -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);
}

20
ErsatzTV.Core/Metadata/LocalMediaScanner.cs

@ -66,8 +66,19 @@ namespace ErsatzTV.Core.Metadata @@ -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 @@ -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 @@ -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"));
}
}

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

@ -24,7 +24,10 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -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 @@ -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}", @@ -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

1
ErsatzTV.sln.DotSettings

@ -11,6 +11,7 @@ @@ -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>

2
ErsatzTV/ErsatzTV.csproj

@ -20,7 +20,7 @@ @@ -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" />

29
ErsatzTV/Shared/MediaCard.razor

@ -1,16 +1,17 @@ @@ -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 @@ @@ -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();
}
}

2
ErsatzTV/Shared/MediaItemsGrid.razor

@ -21,7 +21,7 @@ @@ -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>

15
ErsatzTV/wwwroot/css/site.css

@ -8,12 +8,16 @@ @@ -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 @@ @@ -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; }
Loading…
Cancel
Save