Browse Source

add trash system for local libraries (#571)

* flag local movies as file not found

* show warning icon on cards

* unflag movie that is found during scan

* skip missing files when building playouts

* add state to search index

* add file not found health check

* link to search from file not found health check

* support flagging other media kinds as file not found

* continue to schedule missing items

* support episode files not found

* wip trash page

* fix trash url

* trash page is functional

* update changelog

* fix changelog merge
pull/572/head
Jason Dove 4 years ago committed by GitHub
parent
commit
afa52ccc89
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 2
      .github/workflows/release.yml
  2. 13
      CHANGELOG.md
  3. 1
      ErsatzTV.Application/ErsatzTV.Application.csproj
  4. 9
      ErsatzTV.Application/Maintenance/Commands/DeleteItemsFromDatabase.cs
  5. 38
      ErsatzTV.Application/Maintenance/Commands/DeleteItemsFromDatabaseHandler.cs
  6. 8
      ErsatzTV.Application/MediaCards/ActorCardViewModel.cs
  7. 23
      ErsatzTV.Application/MediaCards/ArtistCardViewModel.cs
  8. 33
      ErsatzTV.Application/MediaCards/Mapper.cs
  9. 12
      ErsatzTV.Application/MediaCards/MediaCardViewModel.cs
  10. 23
      ErsatzTV.Application/MediaCards/MovieCardViewModel.cs
  11. 11
      ErsatzTV.Application/MediaCards/MusicVideoCardViewModel.cs
  12. 10
      ErsatzTV.Application/MediaCards/OtherVideoCardViewModel.cs
  13. 11
      ErsatzTV.Application/MediaCards/SongCardViewModel.cs
  14. 8
      ErsatzTV.Application/MediaCards/TelevisionEpisodeCardViewModel.cs
  15. 10
      ErsatzTV.Application/MediaCards/TelevisionSeasonCardViewModel.cs
  16. 23
      ErsatzTV.Application/MediaCards/TelevisionShowCardViewModel.cs
  17. 2
      ErsatzTV.Application/MediaCollections/Mapper.cs
  18. 10
      ErsatzTV.Application/MediaCollections/MediaCollectionViewModel.cs
  19. 5
      ErsatzTV.Application/Movies/Mapper.cs
  20. 5
      ErsatzTV.Application/Movies/MovieViewModel.cs
  21. 2
      ErsatzTV.Application/Movies/Queries/GetMovieByIdHandler.cs
  22. 3
      ErsatzTV.Core.Tests/Fakes/FakeLocalFileSystem.cs
  23. 47
      ErsatzTV.Core.Tests/Metadata/MovieFolderScannerTests.cs
  24. 22
      ErsatzTV.Core/Domain/MediaItem/MediaItem.cs
  25. 7
      ErsatzTV.Core/Domain/MediaItem/MediaItemState.cs
  26. 1
      ErsatzTV.Core/ErsatzTV.Core.csproj
  27. 5
      ErsatzTV.Core/Health/Checks/IFileNotFoundHealthCheck.cs
  28. 6
      ErsatzTV.Core/Health/HealthCheckResult.cs
  29. 1
      ErsatzTV.Core/Interfaces/Metadata/ILocalFileSystem.cs
  30. 1
      ErsatzTV.Core/Interfaces/Repositories/ILibraryRepository.cs
  31. 5
      ErsatzTV.Core/Interfaces/Repositories/IMediaItemRepository.cs
  32. 1
      ErsatzTV.Core/Interfaces/Search/ISearchIndex.cs
  33. 2
      ErsatzTV.Core/Metadata/LocalFileSystem.cs
  34. 26
      ErsatzTV.Core/Metadata/LocalFolderScanner.cs
  35. 13
      ErsatzTV.Core/Metadata/MovieFolderScanner.cs
  36. 13
      ErsatzTV.Core/Metadata/MusicVideoFolderScanner.cs
  37. 13
      ErsatzTV.Core/Metadata/OtherVideoFolderScanner.cs
  38. 13
      ErsatzTV.Core/Metadata/SongFolderScanner.cs
  39. 13
      ErsatzTV.Core/Metadata/TelevisionFolderScanner.cs
  40. 16
      ErsatzTV.Core/Scheduling/PlayoutBuilder.cs
  41. 7
      ErsatzTV.Infrastructure/Data/Repositories/ArtistRepository.cs
  42. 28
      ErsatzTV.Infrastructure/Data/Repositories/LibraryRepository.cs
  43. 16
      ErsatzTV.Infrastructure/Data/Repositories/MediaCollectionRepository.cs
  44. 41
      ErsatzTV.Infrastructure/Data/Repositories/MediaItemRepository.cs
  45. 16
      ErsatzTV.Infrastructure/Data/Repositories/MovieRepository.cs
  46. 14
      ErsatzTV.Infrastructure/Data/Repositories/MusicVideoRepository.cs
  47. 62
      ErsatzTV.Infrastructure/Data/Repositories/TelevisionRepository.cs
  48. 1
      ErsatzTV.Infrastructure/ErsatzTV.Infrastructure.csproj
  49. 17
      ErsatzTV.Infrastructure/Health/Checks/BaseHealthCheck.cs
  50. 2
      ErsatzTV.Infrastructure/Health/Checks/FFmpegVersionHealthCheck.cs
  51. 76
      ErsatzTV.Infrastructure/Health/Checks/FileNotFoundHealthCheck.cs
  52. 30
      ErsatzTV.Infrastructure/Health/Checks/ZeroDurationHealthCheck.cs
  53. 6
      ErsatzTV.Infrastructure/Health/HealthCheckService.cs
  54. 3858
      ErsatzTV.Infrastructure/Migrations/20211213165714_Add_MediaItemState.Designer.cs
  55. 26
      ErsatzTV.Infrastructure/Migrations/20211213165714_Add_MediaItemState.cs
  56. 3
      ErsatzTV.Infrastructure/Migrations/TvContextModelSnapshot.cs
  57. 28
      ErsatzTV.Infrastructure/Search/SearchIndex.cs
  58. 1
      ErsatzTV/ErsatzTV.csproj
  59. 28
      ErsatzTV/Pages/Artist.razor
  60. 18
      ErsatzTV/Pages/Index.razor
  61. 44
      ErsatzTV/Pages/Movie.razor
  62. 2
      ErsatzTV/Pages/MultiSelectBase.cs
  63. 32
      ErsatzTV/Pages/TelevisionEpisodeList.razor
  64. 564
      ErsatzTV/Pages/Trash.razor
  65. 2
      ErsatzTV/Shared/AddToCollectionDialog.razor
  66. 53
      ErsatzTV/Shared/DeleteFromDatabaseDialog.razor
  67. 1
      ErsatzTV/Shared/MainLayout.razor
  68. 6
      ErsatzTV/Shared/MediaCard.razor
  69. 1
      ErsatzTV/Startup.cs
  70. 4
      ErsatzTV/ViewModels/MultiCollectionSmartItemEditViewModel.cs
  71. 2
      docker/Dockerfile
  72. 2
      docker/nvidia/Dockerfile
  73. 2
      docker/vaapi/Dockerfile

2
.github/workflows/release.yml

@ -47,7 +47,7 @@ jobs: @@ -47,7 +47,7 @@ jobs:
release_name="ErsatzTV-$tag-${{ matrix.target }}"
# Build everything
dotnet publish ErsatzTV/ErsatzTV.csproj --framework net6.0 --runtime "${{ matrix.target }}" -c Release -o "$release_name" /property:InformationalVersion="${tag:1}-${{ matrix.target }}" /property:EnableCompressionInSingleFile=true /property:PublishSingleFile=true --self-contained true
dotnet publish ErsatzTV/ErsatzTV.csproj --framework net6.0 --runtime "${{ matrix.target }}" -c Release -o "$release_name" /property:InformationalVersion="${tag:1}-${{ matrix.target }}" /property:EnableCompressionInSingleFile=true /property:DebugType=Embedded /property:PublishSingleFile=true --self-contained true
# Pack files
if [ "${{ matrix.target }}" == "win-x64" ]; then

13
CHANGELOG.md

@ -5,8 +5,19 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). @@ -5,8 +5,19 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
## [Unreleased]
### Fixed
- Fix local folder scanners to properly detect removed/re-added folders with unchanged contents
- Fix double-click startup on mac
### Added
- Add trash system for local libraries to maintain collection and schedule integrity through media share outages
- When items are missing from disk, they will be flagged and present in the `Media` > `Trash` page
- The trash page can be used to permanently remove missing items from the database
- When items reappear at the expected location on disk, they will be unflagged and removed from the trash
### Changed
- Local libraries only: when items are missing from disk, they will be added to the trash and no longer removed from collections, etc.
- Show song thumbnail in song list
## [0.3.6-alpha] - 2022-01-10
### Fixed
- Properly index `minutes` field when adding new items during scan (vs when rebuilding index)
@ -53,7 +64,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). @@ -53,7 +64,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
## [0.3.2-alpha] - 2021-12-03
### Fixed
- Fix artwork upload on Windows
- Fix unicode song metadata on Windows`
- Fix unicode song metadata on Windows
- Fix unicode console output on Windows
- Fix TV Show NFO metadata processing when `year` is missing
- Fix song detail outline to help legibility on white backgrounds

1
ErsatzTV.Application/ErsatzTV.Application.csproj

@ -3,7 +3,6 @@ @@ -3,7 +3,6 @@
<PropertyGroup>
<TargetFramework>net6.0</TargetFramework>
<NoWarn>VSTHRD200</NoWarn>
<DebugType>embedded</DebugType>
</PropertyGroup>
<ItemGroup>

9
ErsatzTV.Application/Maintenance/Commands/DeleteItemsFromDatabase.cs

@ -0,0 +1,9 @@ @@ -0,0 +1,9 @@
using System.Collections.Generic;
using ErsatzTV.Core;
using LanguageExt;
using MediatR;
using Unit = LanguageExt.Unit;
namespace ErsatzTV.Application.Maintenance.Commands;
public record DeleteItemsFromDatabase(List<int> MediaItemIds) : IRequest<Either<BaseError, Unit>>;

38
ErsatzTV.Application/Maintenance/Commands/DeleteItemsFromDatabaseHandler.cs

@ -0,0 +1,38 @@ @@ -0,0 +1,38 @@
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Core;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Search;
using LanguageExt;
namespace ErsatzTV.Application.Maintenance.Commands
{
public class
DeleteItemsFromDatabaseHandler : MediatR.IRequestHandler<DeleteItemsFromDatabase, Either<BaseError, Unit>>
{
private readonly IMediaItemRepository _mediaItemRepository;
private readonly ISearchIndex _searchIndex;
public DeleteItemsFromDatabaseHandler(
IMediaItemRepository mediaItemRepository,
ISearchIndex searchIndex)
{
_mediaItemRepository = mediaItemRepository;
_searchIndex = searchIndex;
}
public async Task<Either<BaseError, Unit>> Handle(
DeleteItemsFromDatabase request,
CancellationToken cancellationToken)
{
Either<BaseError, Unit> deleteResult = await _mediaItemRepository.DeleteItems(request.MediaItemIds);
if (deleteResult.IsRight)
{
await _searchIndex.RemoveItems(request.MediaItemIds);
_searchIndex.Commit();
}
return deleteResult;
}
}
}

8
ErsatzTV.Application/MediaCards/ActorCardViewModel.cs

@ -1,5 +1,7 @@ @@ -1,5 +1,7 @@
namespace ErsatzTV.Application.MediaCards
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.MediaCards
{
public record ActorCardViewModel(int Id, string Name, string Role, string Thumb) :
MediaCardViewModel(Id, Name, Role, Name, Thumb);
public record ActorCardViewModel(int Id, string Name, string Role, string Thumb, MediaItemState State) :
MediaCardViewModel(Id, Name, Role, Name, Thumb, State);
}

23
ErsatzTV.Application/MediaCards/ArtistCardViewModel.cs

@ -1,10 +1,19 @@ @@ -1,10 +1,19 @@
namespace ErsatzTV.Application.MediaCards
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.MediaCards
{
public record ArtistCardViewModel
(int ArtistId, string Title, string Subtitle, string SortTitle, string Poster) : MediaCardViewModel(
ArtistId,
Title,
Subtitle,
SortTitle,
Poster);
(
int ArtistId,
string Title,
string Subtitle,
string SortTitle,
string Poster,
MediaItemState State) : MediaCardViewModel(
ArtistId,
Title,
Subtitle,
SortTitle,
Poster,
State);
}

33
ErsatzTV.Application/MediaCards/Mapper.cs

@ -2,6 +2,7 @@ @@ -2,6 +2,7 @@
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Emby;
using ErsatzTV.Core.Extensions;
using ErsatzTV.Core.Jellyfin;
using LanguageExt;
using static LanguageExt.Prelude;
@ -19,7 +20,8 @@ namespace ErsatzTV.Application.MediaCards @@ -19,7 +20,8 @@ namespace ErsatzTV.Application.MediaCards
showMetadata.Title,
showMetadata.Year?.ToString(),
showMetadata.SortTitle,
GetPoster(showMetadata, maybeJellyfin, maybeEmby));
GetPoster(showMetadata, maybeJellyfin, maybeEmby),
showMetadata.Show.State);
internal static TelevisionSeasonCardViewModel ProjectToViewModel(
Season season,
@ -34,7 +36,8 @@ namespace ErsatzTV.Application.MediaCards @@ -34,7 +36,8 @@ namespace ErsatzTV.Application.MediaCards
GetSeasonName(season.SeasonNumber),
season.SeasonMetadata.HeadOrNone().Map(sm => GetPoster(sm, maybeJellyfin, maybeEmby))
.IfNone(string.Empty),
season.SeasonNumber == 0 ? "S" : season.SeasonNumber.ToString());
season.SeasonNumber == 0 ? "S" : season.SeasonNumber.ToString(),
season.State);
internal static TelevisionSeasonCardViewModel ProjectToViewModel(
SeasonMetadata seasonMetadata,
@ -53,7 +56,8 @@ namespace ErsatzTV.Application.MediaCards @@ -53,7 +56,8 @@ namespace ErsatzTV.Application.MediaCards
GetSeasonName(seasonMetadata.Season.SeasonNumber),
$"{showTitle}_{seasonMetadata.Season.SeasonNumber:0000}",
GetPoster(seasonMetadata, maybeJellyfin, maybeEmby),
seasonMetadata.Season.SeasonNumber == 0 ? "S" : seasonMetadata.Season.SeasonNumber.ToString());
seasonMetadata.Season.SeasonNumber == 0 ? "S" : seasonMetadata.Season.SeasonNumber.ToString(),
seasonMetadata.Season.State);
}
internal static TelevisionEpisodeCardViewModel ProjectToViewModel(
@ -80,7 +84,9 @@ namespace ErsatzTV.Application.MediaCards @@ -80,7 +84,9 @@ namespace ErsatzTV.Application.MediaCards
? GetEpisodePoster(episodeMetadata, maybeJellyfin, maybeEmby)
: GetThumbnail(episodeMetadata, maybeJellyfin, maybeEmby),
episodeMetadata.Directors.Map(d => d.Name).ToList(),
episodeMetadata.Writers.Map(w => w.Name).ToList());
episodeMetadata.Writers.Map(w => w.Name).ToList(),
episodeMetadata.Episode.State,
episodeMetadata.Episode.GetHeadVersion().MediaFiles.Head().Path);
internal static MovieCardViewModel ProjectToViewModel(
MovieMetadata movieMetadata,
@ -91,7 +97,8 @@ namespace ErsatzTV.Application.MediaCards @@ -91,7 +97,8 @@ namespace ErsatzTV.Application.MediaCards
movieMetadata.Title,
movieMetadata.Year?.ToString(),
movieMetadata.SortTitle,
GetPoster(movieMetadata, maybeJellyfin, maybeEmby));
GetPoster(movieMetadata, maybeJellyfin, maybeEmby),
movieMetadata.Movie.State);
internal static MusicVideoCardViewModel ProjectToViewModel(MusicVideoMetadata musicVideoMetadata) =>
new(
@ -101,14 +108,17 @@ namespace ErsatzTV.Application.MediaCards @@ -101,14 +108,17 @@ namespace ErsatzTV.Application.MediaCards
musicVideoMetadata.SortTitle,
musicVideoMetadata.Plot,
musicVideoMetadata.Album,
GetThumbnail(musicVideoMetadata, None, None));
GetThumbnail(musicVideoMetadata, None, None),
musicVideoMetadata.MusicVideo.State,
musicVideoMetadata.MusicVideo.GetHeadVersion().MediaFiles.Head().Path);
internal static OtherVideoCardViewModel ProjectToViewModel(OtherVideoMetadata otherVideoMetadata) =>
new(
otherVideoMetadata.OtherVideoId,
otherVideoMetadata.Title,
otherVideoMetadata.OriginalTitle,
otherVideoMetadata.SortTitle);
otherVideoMetadata.SortTitle,
otherVideoMetadata.OtherVideo.State);
internal static SongCardViewModel ProjectToViewModel(SongMetadata songMetadata)
{
@ -117,7 +127,9 @@ namespace ErsatzTV.Application.MediaCards @@ -117,7 +127,9 @@ namespace ErsatzTV.Application.MediaCards
songMetadata.SongId,
songMetadata.Title,
songMetadata.Artist + album,
songMetadata.SortTitle);
songMetadata.SortTitle,
GetThumbnail(songMetadata, None, None),
songMetadata.Song.State);
}
internal static ArtistCardViewModel ProjectToViewModel(ArtistMetadata artistMetadata) =>
@ -126,7 +138,8 @@ namespace ErsatzTV.Application.MediaCards @@ -126,7 +138,8 @@ namespace ErsatzTV.Application.MediaCards
artistMetadata.Title,
artistMetadata.Disambiguation,
artistMetadata.SortTitle,
GetThumbnail(artistMetadata, None, None));
GetThumbnail(artistMetadata, None, None),
artistMetadata.Artist.State);
internal static CollectionCardResultsViewModel
ProjectToViewModel(
@ -174,7 +187,7 @@ namespace ErsatzTV.Application.MediaCards @@ -174,7 +187,7 @@ namespace ErsatzTV.Application.MediaCards
.SetQueryParam("maxHeight", 440);
}
return new ActorCardViewModel(actor.Id, actor.Name, actor.Role, artwork);
return new ActorCardViewModel(actor.Id, actor.Name, actor.Role, artwork, MediaItemState.Normal);
}
private static int GetCustomIndex(Collection collection, int mediaItemId) =>

12
ErsatzTV.Application/MediaCards/MediaCardViewModel.cs

@ -1,4 +1,12 @@ @@ -1,4 +1,12 @@
namespace ErsatzTV.Application.MediaCards
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.MediaCards
{
public record MediaCardViewModel(int MediaItemId, string Title, string Subtitle, string SortTitle, string Poster);
public record MediaCardViewModel(
int MediaItemId,
string Title,
string Subtitle,
string SortTitle,
string Poster,
MediaItemState State);
}

23
ErsatzTV.Application/MediaCards/MovieCardViewModel.cs

@ -1,12 +1,21 @@ @@ -1,12 +1,21 @@
namespace ErsatzTV.Application.MediaCards
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.MediaCards
{
public record MovieCardViewModel
(int MovieId, string Title, string Subtitle, string SortTitle, string Poster) : MediaCardViewModel(
MovieId,
Title,
Subtitle,
SortTitle,
Poster)
(
int MovieId,
string Title,
string Subtitle,
string SortTitle,
string Poster,
MediaItemState State) : MediaCardViewModel(
MovieId,
Title,
Subtitle,
SortTitle,
Poster,
State)
{
public int CustomIndex { get; set; }
}

11
ErsatzTV.Application/MediaCards/MusicVideoCardViewModel.cs

@ -1,4 +1,6 @@ @@ -1,4 +1,6 @@
namespace ErsatzTV.Application.MediaCards
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.MediaCards
{
public record MusicVideoCardViewModel
(
@ -8,12 +10,15 @@ @@ -8,12 +10,15 @@
string SortTitle,
string Plot,
string Album,
string Poster) : MediaCardViewModel(
string Poster,
MediaItemState State,
string Path) : MediaCardViewModel(
MusicVideoId,
Title,
Subtitle,
SortTitle,
Poster)
Poster,
State)
{
public int CustomIndex { get; set; }
}

10
ErsatzTV.Application/MediaCards/OtherVideoCardViewModel.cs

@ -1,16 +1,20 @@ @@ -1,16 +1,20 @@
namespace ErsatzTV.Application.MediaCards
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.MediaCards
{
public record OtherVideoCardViewModel
(
int OtherVideoId,
string Title,
string Subtitle,
string SortTitle) : MediaCardViewModel(
string SortTitle,
MediaItemState State) : MediaCardViewModel(
OtherVideoId,
Title,
Subtitle,
SortTitle,
null)
null,
State)
{
public int CustomIndex { get; set; }
}

11
ErsatzTV.Application/MediaCards/SongCardViewModel.cs

@ -1,16 +1,21 @@ @@ -1,16 +1,21 @@
namespace ErsatzTV.Application.MediaCards
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.MediaCards
{
public record SongCardViewModel
(
int SongId,
string Title,
string Subtitle,
string SortTitle) : MediaCardViewModel(
string SortTitle,
string Poster,
MediaItemState State) : MediaCardViewModel(
SongId,
Title,
Subtitle,
SortTitle,
null)
Poster,
State)
{
public int CustomIndex { get; set; }
}

8
ErsatzTV.Application/MediaCards/TelevisionEpisodeCardViewModel.cs

@ -1,5 +1,6 @@ @@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.MediaCards
{
@ -17,10 +18,13 @@ namespace ErsatzTV.Application.MediaCards @@ -17,10 +18,13 @@ namespace ErsatzTV.Application.MediaCards
string Plot,
string Poster,
List<string> Directors,
List<string> Writers) : MediaCardViewModel(
List<string> Writers,
MediaItemState State,
string Path) : MediaCardViewModel(
EpisodeId,
Title,
$"Episode {Episode}",
SortTitle,
Poster);
Poster,
State);
}

10
ErsatzTV.Application/MediaCards/TelevisionSeasonCardViewModel.cs

@ -1,4 +1,6 @@ @@ -1,4 +1,6 @@
namespace ErsatzTV.Application.MediaCards
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.MediaCards
{
public record TelevisionSeasonCardViewModel
(
@ -9,10 +11,12 @@ @@ -9,10 +11,12 @@
string Subtitle,
string SortTitle,
string Poster,
string Placeholder) : MediaCardViewModel(
string Placeholder,
MediaItemState State) : MediaCardViewModel(
TelevisionSeasonId,
Title,
Subtitle,
SortTitle,
Poster);
Poster,
State);
}

23
ErsatzTV.Application/MediaCards/TelevisionShowCardViewModel.cs

@ -1,10 +1,19 @@ @@ -1,10 +1,19 @@
namespace ErsatzTV.Application.MediaCards
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.MediaCards
{
public record TelevisionShowCardViewModel
(int TelevisionShowId, string Title, string Subtitle, string SortTitle, string Poster) : MediaCardViewModel(
TelevisionShowId,
Title,
Subtitle,
SortTitle,
Poster);
(
int TelevisionShowId,
string Title,
string Subtitle,
string SortTitle,
string Poster,
MediaItemState State) : MediaCardViewModel(
TelevisionShowId,
Title,
Subtitle,
SortTitle,
Poster,
State);
}

2
ErsatzTV.Application/MediaCollections/Mapper.cs

@ -7,7 +7,7 @@ namespace ErsatzTV.Application.MediaCollections @@ -7,7 +7,7 @@ namespace ErsatzTV.Application.MediaCollections
internal static class Mapper
{
internal static MediaCollectionViewModel ProjectToViewModel(Collection collection) =>
new(collection.Id, collection.Name, collection.UseCustomPlaybackOrder);
new(collection.Id, collection.Name, collection.UseCustomPlaybackOrder, MediaItemState.Normal);
internal static MultiCollectionViewModel ProjectToViewModel(MultiCollection multiCollection) =>
new(

10
ErsatzTV.Application/MediaCollections/MediaCollectionViewModel.cs

@ -1,11 +1,17 @@ @@ -1,11 +1,17 @@
using ErsatzTV.Application.MediaCards;
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.MediaCollections
{
public record MediaCollectionViewModel(int Id, string Name, bool UseCustomPlaybackOrder) : MediaCardViewModel(
public record MediaCollectionViewModel(
int Id,
string Name,
bool UseCustomPlaybackOrder,
MediaItemState State) : MediaCardViewModel(
Id,
Name,
string.Empty,
Name,
string.Empty);
string.Empty,
State);
}

5
ErsatzTV.Application/Movies/Mapper.cs

@ -4,6 +4,7 @@ using System.Globalization; @@ -4,6 +4,7 @@ using System.Globalization;
using System.Linq;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Emby;
using ErsatzTV.Core.Extensions;
using ErsatzTV.Core.Jellyfin;
using Flurl;
using LanguageExt;
@ -34,7 +35,9 @@ namespace ErsatzTV.Application.Movies @@ -34,7 +35,9 @@ namespace ErsatzTV.Application.Movies
.Map(a => MediaCards.Mapper.ProjectToViewModel(a, maybeJellyfin, maybeEmby))
.ToList(),
metadata.Directors.Map(d => d.Name).ToList(),
metadata.Writers.Map(w => w.Name).ToList())
metadata.Writers.Map(w => w.Name).ToList(),
movie.GetHeadVersion().MediaFiles.Head().Path,
movie.State)
{
Poster = Artwork(metadata, ArtworkKind.Poster, maybeJellyfin, maybeEmby),
FanArt = Artwork(metadata, ArtworkKind.FanArt, maybeJellyfin, maybeEmby)

5
ErsatzTV.Application/Movies/MovieViewModel.cs

@ -1,6 +1,7 @@ @@ -1,6 +1,7 @@
using System.Collections.Generic;
using System.Globalization;
using ErsatzTV.Application.MediaCards;
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.Movies
{
@ -15,7 +16,9 @@ namespace ErsatzTV.Application.Movies @@ -15,7 +16,9 @@ namespace ErsatzTV.Application.Movies
List<CultureInfo> Languages,
List<ActorCardViewModel> Actors,
List<string> Directors,
List<string> Writers)
List<string> Writers,
string Path,
MediaItemState MediaItemState)
{
public string Poster { get; set; }
public string FanArt { get; set; }

2
ErsatzTV.Application/Movies/Queries/GetMovieByIdHandler.cs

@ -33,7 +33,7 @@ namespace ErsatzTV.Application.Movies.Queries @@ -33,7 +33,7 @@ namespace ErsatzTV.Application.Movies.Queries
GetMovieById request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
Option<JellyfinMediaSource> maybeJellyfin = await _mediaSourceRepository.GetAllJellyfin()
.Map(list => list.HeadOrNone());

3
ErsatzTV.Core.Tests/Fakes/FakeLocalFileSystem.cs

@ -44,7 +44,7 @@ namespace ErsatzTV.Core.Tests.Fakes @@ -44,7 +44,7 @@ namespace ErsatzTV.Core.Tests.Fakes
.IfNone(SystemTime.MinValueUtc);
public bool IsLibraryPathAccessible(LibraryPath libraryPath) =>
_files.Any(f => f.Path.StartsWith(libraryPath.Path + Path.DirectorySeparatorChar));
_folders.Any(f => f.Path == libraryPath.Path);
public IEnumerable<string> ListSubdirectories(string folder) =>
_folders.Map(f => f.Path).Filter(f => f.StartsWith(folder) && Directory.GetParent(f)?.FullName == folder);
@ -53,6 +53,7 @@ namespace ErsatzTV.Core.Tests.Fakes @@ -53,6 +53,7 @@ namespace ErsatzTV.Core.Tests.Fakes
_files.Map(f => f.Path).Filter(f => Path.GetDirectoryName(f) == folder);
public bool FileExists(string path) => _files.Any(f => f.Path == path);
public bool FolderExists(string folder) => false;
public Task<byte[]> ReadAllBytes(string path) => TestBytes.AsTask();

47
ErsatzTV.Core.Tests/Metadata/MovieFolderScannerTests.cs

@ -6,7 +6,6 @@ using System.Runtime.InteropServices; @@ -6,7 +6,6 @@ using System.Runtime.InteropServices;
using System.Threading.Tasks;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.FFmpeg;
using ErsatzTV.Core.Interfaces.FFmpeg;
using ErsatzTV.Core.Interfaces.Images;
using ErsatzTV.Core.Interfaces.Metadata;
@ -54,6 +53,10 @@ namespace ErsatzTV.Core.Tests.Metadata @@ -54,6 +53,10 @@ namespace ErsatzTV.Core.Tests.Metadata
_movieRepository.Setup(x => x.FindMoviePaths(It.IsAny<LibraryPath>()))
.Returns(new List<string>().AsEnumerable().AsTask());
_mediaItemRepository = new Mock<IMediaItemRepository>();
_mediaItemRepository.Setup(x => x.FlagFileNotFound(It.IsAny<LibraryPath>(), It.IsAny<string>()))
.Returns(new List<int>().AsTask());
_localStatisticsProvider = new Mock<ILocalStatisticsProvider>();
_localMetadataProvider = new Mock<ILocalMetadataProvider>();
@ -73,6 +76,7 @@ namespace ErsatzTV.Core.Tests.Metadata @@ -73,6 +76,7 @@ namespace ErsatzTV.Core.Tests.Metadata
}
private Mock<IMovieRepository> _movieRepository;
private Mock<IMediaItemRepository> _mediaItemRepository;
private Mock<ILocalStatisticsProvider> _localStatisticsProvider;
private Mock<ILocalMetadataProvider> _localMetadataProvider;
private Mock<IImageCache> _imageCache;
@ -535,6 +539,9 @@ namespace ErsatzTV.Core.Tests.Metadata @@ -535,6 +539,9 @@ namespace ErsatzTV.Core.Tests.Metadata
[Test]
public async Task RenamedMovie_Should_Delete_Old_Movie()
{
// TODO: handle this case more elegantly
// ideally, detect that the movie was renamed and still delete the old one (or update the path?)
string movieFolder = Path.Combine(FakeRoot, "Movie (2020)");
string oldMoviePath = Path.Combine(movieFolder, "Movie (2020).avi");
@ -557,12 +564,14 @@ namespace ErsatzTV.Core.Tests.Metadata @@ -557,12 +564,14 @@ namespace ErsatzTV.Core.Tests.Metadata
result.IsRight.Should().BeTrue();
_movieRepository.Verify(x => x.DeleteByPath(It.IsAny<LibraryPath>(), It.IsAny<string>()), Times.Once);
_movieRepository.Verify(x => x.DeleteByPath(libraryPath, oldMoviePath), Times.Once);
_mediaItemRepository.Verify(
x => x.FlagFileNotFound(It.IsAny<LibraryPath>(), It.IsAny<string>()),
Times.Once);
_mediaItemRepository.Verify(x => x.FlagFileNotFound(libraryPath, oldMoviePath), Times.Once);
}
[Test]
public async Task DeletedMovieAndFolder_Should_Delete_Old_Movie()
public async Task DeletedMovieAndFolder_Should_Flag_File_Not_Found()
{
string movieFolder = Path.Combine(FakeRoot, "Movie (2020)");
string oldMoviePath = Path.Combine(movieFolder, "Movie (2020).avi");
@ -570,10 +579,8 @@ namespace ErsatzTV.Core.Tests.Metadata @@ -570,10 +579,8 @@ namespace ErsatzTV.Core.Tests.Metadata
_movieRepository.Setup(x => x.FindMoviePaths(It.IsAny<LibraryPath>()))
.Returns(new List<string> { oldMoviePath }.AsEnumerable().AsTask());
string moviePath = Path.Combine(movieFolder, "Movie (2020).mkv");
MovieFolderScanner service = GetService(
new FakeFileEntry(moviePath) { LastWriteTime = DateTime.Now }
new FakeFolderEntry(FakeRoot)
);
var libraryPath = new LibraryPath
{ Id = 1, Path = FakeRoot, LibraryFolders = new List<LibraryFolder>() };
@ -586,11 +593,12 @@ namespace ErsatzTV.Core.Tests.Metadata @@ -586,11 +593,12 @@ namespace ErsatzTV.Core.Tests.Metadata
result.IsRight.Should().BeTrue();
_movieRepository.Verify(x => x.DeleteByPath(It.IsAny<LibraryPath>(), It.IsAny<string>()), Times.Once);
_movieRepository.Verify(x => x.DeleteByPath(libraryPath, oldMoviePath), Times.Once);
_mediaItemRepository.Verify(
x => x.FlagFileNotFound(It.IsAny<LibraryPath>(), It.IsAny<string>()),
Times.Once);
_mediaItemRepository.Verify(x => x.FlagFileNotFound(libraryPath, oldMoviePath), Times.Once);
}
private MovieFolderScanner GetService(params FakeFileEntry[] files) =>
new(
new FakeLocalFileSystem(new List<FakeFileEntry>(files)),
@ -602,6 +610,25 @@ namespace ErsatzTV.Core.Tests.Metadata @@ -602,6 +610,25 @@ namespace ErsatzTV.Core.Tests.Metadata
new Mock<ISearchIndex>().Object,
new Mock<ISearchRepository>().Object,
new Mock<ILibraryRepository>().Object,
_mediaItemRepository.Object,
new Mock<IMediator>().Object,
null,
new Mock<ITempFilePool>().Object,
new Mock<ILogger<MovieFolderScanner>>().Object
);
private MovieFolderScanner GetService(params FakeFolderEntry[] folders) =>
new(
new FakeLocalFileSystem(new List<FakeFileEntry>(), new List<FakeFolderEntry>(folders)),
_movieRepository.Object,
_localStatisticsProvider.Object,
_localMetadataProvider.Object,
new Mock<IMetadataRepository>().Object,
_imageCache.Object,
new Mock<ISearchIndex>().Object,
new Mock<ISearchRepository>().Object,
new Mock<ILibraryRepository>().Object,
_mediaItemRepository.Object,
new Mock<IMediator>().Object,
null,
new Mock<ITempFilePool>().Object,

22
ErsatzTV.Core/Domain/MediaItem/MediaItem.cs

@ -1,14 +1,14 @@ @@ -1,14 +1,14 @@
using System.Collections.Generic;
namespace ErsatzTV.Core.Domain
namespace ErsatzTV.Core.Domain;
public class MediaItem
{
public class MediaItem
{
public int Id { get; set; }
public int LibraryPathId { get; set; }
public LibraryPath LibraryPath { get; set; }
public List<Collection> Collections { get; set; }
public List<CollectionItem> CollectionItems { get; set; }
public List<TraktListItem> TraktListItems { get; set; }
}
}
public int Id { get; set; }
public int LibraryPathId { get; set; }
public LibraryPath LibraryPath { get; set; }
public List<Collection> Collections { get; set; }
public List<CollectionItem> CollectionItems { get; set; }
public List<TraktListItem> TraktListItems { get; set; }
public MediaItemState State { get; set; }
}

7
ErsatzTV.Core/Domain/MediaItem/MediaItemState.cs

@ -0,0 +1,7 @@ @@ -0,0 +1,7 @@
namespace ErsatzTV.Core.Domain;
public enum MediaItemState
{
Normal = 0,
FileNotFound = 1
}

1
ErsatzTV.Core/ErsatzTV.Core.csproj

@ -3,7 +3,6 @@ @@ -3,7 +3,6 @@
<PropertyGroup>
<TargetFramework>net6.0</TargetFramework>
<NoWarn>VSTHRD200</NoWarn>
<DebugType>embedded</DebugType>
</PropertyGroup>
<ItemGroup>

5
ErsatzTV.Core/Health/Checks/IFileNotFoundHealthCheck.cs

@ -0,0 +1,5 @@ @@ -0,0 +1,5 @@
namespace ErsatzTV.Core.Health.Checks;
public interface IFileNotFoundHealthCheck : IHealthCheck
{
}

6
ErsatzTV.Core/Health/HealthCheckResult.cs

@ -1,4 +1,6 @@ @@ -1,4 +1,6 @@
namespace ErsatzTV.Core.Health
using LanguageExt;
namespace ErsatzTV.Core.Health
{
public record HealthCheckResult(string Title, HealthCheckStatus Status, string Message);
public record HealthCheckResult(string Title, HealthCheckStatus Status, string Message, Option<string> Link);
}

1
ErsatzTV.Core/Interfaces/Metadata/ILocalFileSystem.cs

@ -14,6 +14,7 @@ namespace ErsatzTV.Core.Interfaces.Metadata @@ -14,6 +14,7 @@ namespace ErsatzTV.Core.Interfaces.Metadata
IEnumerable<string> ListSubdirectories(string folder);
IEnumerable<string> ListFiles(string folder);
bool FileExists(string path);
bool FolderExists(string folder);
Task<Either<BaseError, Unit>> CopyFile(string source, string destination);
Unit EmptyFolder(string folder);
}

1
ErsatzTV.Core/Interfaces/Repositories/ILibraryRepository.cs

@ -19,5 +19,6 @@ namespace ErsatzTV.Core.Interfaces.Repositories @@ -19,5 +19,6 @@ namespace ErsatzTV.Core.Interfaces.Repositories
Task<List<int>> GetMediaIdsByLocalPath(int libraryPathId);
Task DeleteLocalPath(int libraryPathId);
Task<Unit> SetEtag(LibraryPath libraryPath, Option<LibraryFolder> knownFolder, string path, string etag);
Task<Unit> CleanEtagsForLibraryPath(LibraryPath libraryPath);
}
}

5
ErsatzTV.Core/Interfaces/Repositories/IMediaItemRepository.cs

@ -1,10 +1,15 @@ @@ -1,10 +1,15 @@
using System.Collections.Generic;
using System.Threading.Tasks;
using ErsatzTV.Core.Domain;
using LanguageExt;
namespace ErsatzTV.Core.Interfaces.Repositories
{
public interface IMediaItemRepository
{
Task<List<string>> GetAllLanguageCodes();
Task<List<int>> FlagFileNotFound(LibraryPath libraryPath, string path);
Task<Unit> FlagNormal(MediaItem mediaItem);
Task<Either<BaseError, Unit>> DeleteItems(List<int> mediaItemIds);
}
}

1
ErsatzTV.Core/Interfaces/Search/ISearchIndex.cs

@ -14,6 +14,7 @@ namespace ErsatzTV.Core.Interfaces.Search @@ -14,6 +14,7 @@ namespace ErsatzTV.Core.Interfaces.Search
public int Version { get; }
Task<bool> Initialize(ILocalFileSystem localFileSystem);
Task<Unit> Rebuild(ISearchRepository searchRepository, List<int> itemIds);
Task<Unit> RebuildItems(ISearchRepository searchRepository, List<int> itemIds);
Task<Unit> AddItems(ISearchRepository searchRepository, List<MediaItem> items);
Task<Unit> UpdateItems(ISearchRepository searchRepository, List<MediaItem> items);
Task<Unit> RemoveItems(List<int> ids);

2
ErsatzTV.Core/Metadata/LocalFileSystem.cs

@ -50,6 +50,8 @@ namespace ErsatzTV.Core.Metadata @@ -50,6 +50,8 @@ namespace ErsatzTV.Core.Metadata
public bool FileExists(string path) => File.Exists(path);
public bool FolderExists(string folder) => Directory.Exists(folder);
public async Task<Either<BaseError, Unit>> CopyFile(string source, string destination)
{
try

26
ErsatzTV.Core/Metadata/LocalFolderScanner.cs

@ -57,11 +57,13 @@ namespace ErsatzTV.Core.Metadata @@ -57,11 +57,13 @@ namespace ErsatzTV.Core.Metadata
private readonly ILocalStatisticsProvider _localStatisticsProvider;
private readonly ILogger _logger;
private readonly IMetadataRepository _metadataRepository;
private readonly IMediaItemRepository _mediaItemRepository;
protected LocalFolderScanner(
ILocalFileSystem localFileSystem,
ILocalStatisticsProvider localStatisticsProvider,
IMetadataRepository metadataRepository,
IMediaItemRepository mediaItemRepository,
IImageCache imageCache,
IFFmpegProcessService ffmpegProcessService,
ITempFilePool tempFilePool,
@ -70,6 +72,7 @@ namespace ErsatzTV.Core.Metadata @@ -70,6 +72,7 @@ namespace ErsatzTV.Core.Metadata
_localFileSystem = localFileSystem;
_localStatisticsProvider = localStatisticsProvider;
_metadataRepository = metadataRepository;
_mediaItemRepository = mediaItemRepository;
_imageCache = imageCache;
_ffmpegProcessService = ffmpegProcessService;
_tempFilePool = tempFilePool;
@ -265,6 +268,29 @@ namespace ErsatzTV.Core.Metadata @@ -265,6 +268,29 @@ namespace ErsatzTV.Core.Metadata
return false;
}
protected Task<List<int>> FlagFileNotFound(LibraryPath libraryPath, string path) =>
_mediaItemRepository.FlagFileNotFound(libraryPath, path);
protected async Task<Either<BaseError, MediaItemScanResult<T>>> FlagNormal<T>(MediaItemScanResult<T> result)
where T : MediaItem
{
try
{
T mediaItem = result.Item;
if (mediaItem.State != MediaItemState.Normal)
{
await _mediaItemRepository.FlagNormal(mediaItem);
result.IsUpdated = true;
}
return result;
}
catch (Exception ex)
{
return BaseError.New(ex.ToString());
}
}
protected bool ShouldIncludeFolder(string folder) =>
!Path.GetFileName(folder).StartsWith('.') &&
!_localFileSystem.FileExists(Path.Combine(folder, ".etvignore"));

13
ErsatzTV.Core/Metadata/MovieFolderScanner.cs

@ -40,6 +40,7 @@ namespace ErsatzTV.Core.Metadata @@ -40,6 +40,7 @@ namespace ErsatzTV.Core.Metadata
ISearchIndex searchIndex,
ISearchRepository searchRepository,
ILibraryRepository libraryRepository,
IMediaItemRepository mediaItemRepository,
IMediator mediator,
IFFmpegProcessService ffmpegProcessService,
ITempFilePool tempFilePool,
@ -48,6 +49,7 @@ namespace ErsatzTV.Core.Metadata @@ -48,6 +49,7 @@ namespace ErsatzTV.Core.Metadata
localFileSystem,
localStatisticsProvider,
metadataRepository,
mediaItemRepository,
imageCache,
ffmpegProcessService,
tempFilePool,
@ -140,7 +142,8 @@ namespace ErsatzTV.Core.Metadata @@ -140,7 +142,8 @@ namespace ErsatzTV.Core.Metadata
.BindT(movie => UpdateStatistics(movie, ffprobePath))
.BindT(UpdateMetadata)
.BindT(movie => UpdateArtwork(movie, ArtworkKind.Poster))
.BindT(movie => UpdateArtwork(movie, ArtworkKind.FanArt));
.BindT(movie => UpdateArtwork(movie, ArtworkKind.FanArt))
.BindT(FlagNormal);
await maybeMovie.Match(
async result =>
@ -168,9 +171,9 @@ namespace ErsatzTV.Core.Metadata @@ -168,9 +171,9 @@ namespace ErsatzTV.Core.Metadata
{
if (!_localFileSystem.FileExists(path))
{
_logger.LogInformation("Removing missing movie at {Path}", path);
List<int> ids = await _movieRepository.DeleteByPath(libraryPath, path);
await _searchIndex.RemoveItems(ids);
_logger.LogInformation("Flagging missing movie at {Path}", path);
List<int> ids = await FlagFileNotFound(libraryPath, path);
await _searchIndex.RebuildItems(_searchRepository, ids);
}
else if (Path.GetFileName(path).StartsWith("._"))
{
@ -180,6 +183,8 @@ namespace ErsatzTV.Core.Metadata @@ -180,6 +183,8 @@ namespace ErsatzTV.Core.Metadata
}
}
await _libraryRepository.CleanEtagsForLibraryPath(libraryPath);
_searchIndex.Commit();
return Unit.Default;
}

13
ErsatzTV.Core/Metadata/MusicVideoFolderScanner.cs

@ -41,6 +41,7 @@ namespace ErsatzTV.Core.Metadata @@ -41,6 +41,7 @@ namespace ErsatzTV.Core.Metadata
IArtistRepository artistRepository,
IMusicVideoRepository musicVideoRepository,
ILibraryRepository libraryRepository,
IMediaItemRepository mediaItemRepository,
IMediator mediator,
IFFmpegProcessService ffmpegProcessService,
ITempFilePool tempFilePool,
@ -48,6 +49,7 @@ namespace ErsatzTV.Core.Metadata @@ -48,6 +49,7 @@ namespace ErsatzTV.Core.Metadata
localFileSystem,
localStatisticsProvider,
metadataRepository,
mediaItemRepository,
imageCache,
ffmpegProcessService,
tempFilePool,
@ -135,9 +137,9 @@ namespace ErsatzTV.Core.Metadata @@ -135,9 +137,9 @@ namespace ErsatzTV.Core.Metadata
{
if (!_localFileSystem.FileExists(path))
{
_logger.LogInformation("Removing missing music video at {Path}", path);
List<int> musicVideoIds = await _musicVideoRepository.DeleteByPath(libraryPath, path);
await _searchIndex.RemoveItems(musicVideoIds);
_logger.LogInformation("Flagging missing music video at {Path}", path);
List<int> musicVideoIds = await FlagFileNotFound(libraryPath, path);
await _searchIndex.RebuildItems(_searchRepository, musicVideoIds);
}
else if (Path.GetFileName(path).StartsWith("._"))
{
@ -146,6 +148,8 @@ namespace ErsatzTV.Core.Metadata @@ -146,6 +148,8 @@ namespace ErsatzTV.Core.Metadata
await _searchIndex.RemoveItems(musicVideoIds);
}
}
await _libraryRepository.CleanEtagsForLibraryPath(libraryPath);
List<int> artistIds = await _artistRepository.DeleteEmptyArtists(libraryPath);
await _searchIndex.RemoveItems(artistIds);
@ -276,7 +280,8 @@ namespace ErsatzTV.Core.Metadata @@ -276,7 +280,8 @@ namespace ErsatzTV.Core.Metadata
.GetOrAdd(artist, libraryPath, file)
.BindT(musicVideo => UpdateStatistics(musicVideo, ffprobePath))
.BindT(UpdateMetadata)
.BindT(UpdateThumbnail);
.BindT(UpdateThumbnail)
.BindT(FlagNormal);
await maybeMusicVideo.Match(
async result =>

13
ErsatzTV.Core/Metadata/OtherVideoFolderScanner.cs

@ -40,12 +40,14 @@ namespace ErsatzTV.Core.Metadata @@ -40,12 +40,14 @@ namespace ErsatzTV.Core.Metadata
ISearchRepository searchRepository,
IOtherVideoRepository otherVideoRepository,
ILibraryRepository libraryRepository,
IMediaItemRepository mediaItemRepository,
IFFmpegProcessService ffmpegProcessService,
ITempFilePool tempFilePool,
ILogger<OtherVideoFolderScanner> logger) : base(
localFileSystem,
localStatisticsProvider,
metadataRepository,
mediaItemRepository,
imageCache,
ffmpegProcessService,
tempFilePool,
@ -133,7 +135,8 @@ namespace ErsatzTV.Core.Metadata @@ -133,7 +135,8 @@ namespace ErsatzTV.Core.Metadata
Either<BaseError, MediaItemScanResult<OtherVideo>> maybeVideo = await _otherVideoRepository
.GetOrAdd(libraryPath, file)
.BindT(video => UpdateStatistics(video, ffprobePath))
.BindT(UpdateMetadata);
.BindT(UpdateMetadata)
.BindT(FlagNormal);
await maybeVideo.Match(
async result =>
@ -161,9 +164,9 @@ namespace ErsatzTV.Core.Metadata @@ -161,9 +164,9 @@ namespace ErsatzTV.Core.Metadata
{
if (!_localFileSystem.FileExists(path))
{
_logger.LogInformation("Removing missing other video at {Path}", path);
List<int> otherVideoIds = await _otherVideoRepository.DeleteByPath(libraryPath, path);
await _searchIndex.RemoveItems(otherVideoIds);
_logger.LogInformation("Flagging missing other video at {Path}", path);
List<int> otherVideoIds = await FlagFileNotFound(libraryPath, path);
await _searchIndex.RebuildItems(_searchRepository, otherVideoIds);
}
else if (Path.GetFileName(path).StartsWith("._"))
{
@ -172,6 +175,8 @@ namespace ErsatzTV.Core.Metadata @@ -172,6 +175,8 @@ namespace ErsatzTV.Core.Metadata
await _searchIndex.RemoveItems(otherVideoIds);
}
}
await _libraryRepository.CleanEtagsForLibraryPath(libraryPath);
_searchIndex.Commit();
return Unit.Default;

13
ErsatzTV.Core/Metadata/SongFolderScanner.cs

@ -41,12 +41,14 @@ namespace ErsatzTV.Core.Metadata @@ -41,12 +41,14 @@ namespace ErsatzTV.Core.Metadata
ISearchRepository searchRepository,
ISongRepository songRepository,
ILibraryRepository libraryRepository,
IMediaItemRepository mediaItemRepository,
IFFmpegProcessService ffmpegProcessService,
ITempFilePool tempFilePool,
ILogger<SongFolderScanner> logger) : base(
localFileSystem,
localStatisticsProvider,
metadataRepository,
mediaItemRepository,
imageCache,
ffmpegProcessService,
tempFilePool,
@ -136,7 +138,8 @@ namespace ErsatzTV.Core.Metadata @@ -136,7 +138,8 @@ namespace ErsatzTV.Core.Metadata
.GetOrAdd(libraryPath, file)
.BindT(video => UpdateStatistics(video, ffprobePath))
.BindT(video => UpdateMetadata(video, ffprobePath))
.BindT(video => UpdateThumbnail(video, ffmpegPath));
.BindT(video => UpdateThumbnail(video, ffmpegPath))
.BindT(FlagNormal);
await maybeSong.Match(
async result =>
@ -164,9 +167,9 @@ namespace ErsatzTV.Core.Metadata @@ -164,9 +167,9 @@ namespace ErsatzTV.Core.Metadata
{
if (!_localFileSystem.FileExists(path))
{
_logger.LogInformation("Removing missing song at {Path}", path);
List<int> songIds = await _songRepository.DeleteByPath(libraryPath, path);
await _searchIndex.RemoveItems(songIds);
_logger.LogInformation("Flagging missing song at {Path}", path);
List<int> songIds = await FlagFileNotFound(libraryPath, path);
await _searchIndex.RebuildItems(_searchRepository, songIds);
}
else if (Path.GetFileName(path).StartsWith("._"))
{
@ -175,6 +178,8 @@ namespace ErsatzTV.Core.Metadata @@ -175,6 +178,8 @@ namespace ErsatzTV.Core.Metadata
await _searchIndex.RemoveItems(songIds);
}
}
await _libraryRepository.CleanEtagsForLibraryPath(libraryPath);
_searchIndex.Commit();
return Unit.Default;

13
ErsatzTV.Core/Metadata/TelevisionFolderScanner.cs

@ -40,6 +40,7 @@ namespace ErsatzTV.Core.Metadata @@ -40,6 +40,7 @@ namespace ErsatzTV.Core.Metadata
ISearchIndex searchIndex,
ISearchRepository searchRepository,
ILibraryRepository libraryRepository,
IMediaItemRepository mediaItemRepository,
IMediator mediator,
IFFmpegProcessService ffmpegProcessService,
ITempFilePool tempFilePool,
@ -47,6 +48,7 @@ namespace ErsatzTV.Core.Metadata @@ -47,6 +48,7 @@ namespace ErsatzTV.Core.Metadata
localFileSystem,
localStatisticsProvider,
metadataRepository,
mediaItemRepository,
imageCache,
ffmpegProcessService,
tempFilePool,
@ -126,8 +128,9 @@ namespace ErsatzTV.Core.Metadata @@ -126,8 +128,9 @@ namespace ErsatzTV.Core.Metadata
{
if (!_localFileSystem.FileExists(path))
{
_logger.LogInformation("Removing missing episode at {Path}", path);
await _televisionRepository.DeleteByPath(libraryPath, path);
_logger.LogInformation("Flagging missing episode at {Path}", path);
List<int> episodeIds = await FlagFileNotFound(libraryPath, path);
await _searchIndex.RebuildItems(_searchRepository, episodeIds);
}
else if (Path.GetFileName(path).StartsWith("._"))
{
@ -135,6 +138,8 @@ namespace ErsatzTV.Core.Metadata @@ -135,6 +138,8 @@ namespace ErsatzTV.Core.Metadata
await _televisionRepository.DeleteByPath(libraryPath, path);
}
}
await _libraryRepository.CleanEtagsForLibraryPath(libraryPath);
await _televisionRepository.DeleteEmptySeasons(libraryPath);
List<int> ids = await _televisionRepository.DeleteEmptyShows(libraryPath);
@ -231,7 +236,9 @@ namespace ErsatzTV.Core.Metadata @@ -231,7 +236,9 @@ namespace ErsatzTV.Core.Metadata
episode => UpdateStatistics(new MediaItemScanResult<Episode>(episode), ffprobePath)
.MapT(_ => episode))
.BindT(UpdateMetadata)
.BindT(UpdateThumbnail);
.BindT(UpdateThumbnail)
.BindT(e => FlagNormal(new MediaItemScanResult<Episode>(e)))
.MapT(r => r.Item);
await maybeEpisode.Match(
async episode =>

16
ErsatzTV.Core/Scheduling/PlayoutBuilder.cs

@ -3,6 +3,7 @@ using System.Collections.Generic; @@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Extensions;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Scheduling;
using LanguageExt;
@ -254,6 +255,7 @@ namespace ErsatzTV.Core.Scheduling @@ -254,6 +255,7 @@ namespace ErsatzTV.Core.Scheduling
foreach ((CollectionKey _, List<MediaItem> items) in collectionMediaItems)
{
var zeroItems = new List<MediaItem>();
// var missingItems = new List<MediaItem>();
foreach (MediaItem item in items)
{
@ -272,6 +274,17 @@ namespace ErsatzTV.Core.Scheduling @@ -272,6 +274,17 @@ namespace ErsatzTV.Core.Scheduling
_ => true
};
// if (item.State == MediaItemState.FileNotFound)
// {
// _logger.LogWarning(
// "Skipping media item that does not exist on disk {MediaItem} - {MediaItemTitle} - {Path}",
// item.Id,
// DisplayTitle(item),
// item.GetHeadVersion().MediaFiles.Head().Path);
//
// missingItems.Add(item);
// }
// else
if (isZero)
{
_logger.LogWarning(
@ -283,7 +296,8 @@ namespace ErsatzTV.Core.Scheduling @@ -283,7 +296,8 @@ namespace ErsatzTV.Core.Scheduling
}
}
items.RemoveAll(i => zeroItems.Contains(i));
// items.RemoveAll(missingItems.Contains);
items.RemoveAll(zeroItems.Contains);
}
return collectionMediaItems.Find(c => !c.Value.Any()).Map(c => c.Key);

7
ErsatzTV.Infrastructure/Data/Repositories/ArtistRepository.cs

@ -123,10 +123,11 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -123,10 +123,11 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
public async Task<List<ArtistMetadata>> GetArtistsForCards(List<int> ids)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
return await dbContext.ArtistMetadata
.AsNoTracking()
.Filter(am => ids.Contains(am.ArtistId))
.Include(am => am.Artist)
.Include(am => am.Artwork)
.OrderBy(am => am.SortTitle)
.ToListAsync();
@ -149,12 +150,14 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -149,12 +150,14 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
public async Task<List<MusicVideo>> GetArtistItems(int artistId)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
return await dbContext.MusicVideos
.AsNoTracking()
.Include(mv => mv.MusicVideoMetadata)
.Include(mv => mv.MediaVersions)
.ThenInclude(mv => mv.Chapters)
.Include(m => m.MediaVersions)
.ThenInclude(mv => mv.MediaFiles)
.Include(mv => mv.Artist)
.ThenInclude(a => a.ArtistMetadata)
.Filter(mv => mv.ArtistId == artistId)

28
ErsatzTV.Infrastructure/Data/Repositories/LibraryRepository.cs

@ -4,6 +4,7 @@ using System.Linq; @@ -4,6 +4,7 @@ using System.Linq;
using System.Threading.Tasks;
using Dapper;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories;
using LanguageExt;
using Microsoft.EntityFrameworkCore;
@ -14,10 +15,15 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -14,10 +15,15 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
public class LibraryRepository : ILibraryRepository
{
private readonly IDbConnection _dbConnection;
private readonly ILocalFileSystem _localFileSystem;
private readonly IDbContextFactory<TvContext> _dbContextFactory;
public LibraryRepository(IDbContextFactory<TvContext> dbContextFactory, IDbConnection dbConnection)
public LibraryRepository(
ILocalFileSystem localFileSystem,
IDbContextFactory<TvContext> dbContextFactory,
IDbConnection dbConnection)
{
_localFileSystem = localFileSystem;
_dbContextFactory = dbContextFactory;
_dbConnection = dbConnection;
}
@ -120,7 +126,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -120,7 +126,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
},
async () =>
{
await using TvContext context = _dbContextFactory.CreateDbContext();
await using TvContext context = await _dbContextFactory.CreateDbContextAsync();
await context.LibraryFolders.AddAsync(
new LibraryFolder
{
@ -130,5 +136,23 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -130,5 +136,23 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
});
await context.SaveChangesAsync();
}).ToUnit();
public async Task<Unit> CleanEtagsForLibraryPath(LibraryPath libraryPath)
{
IEnumerable<string> folders = await _dbConnection.QueryAsync<string>(
@"SELECT LF.Path
FROM LibraryFolder LF
WHERE LF.LibraryPathId = @LibraryPathId",
new { LibraryPathId = libraryPath.Id });
foreach (string folder in folders.Where(f => !_localFileSystem.FolderExists(f)))
{
await _dbConnection.ExecuteAsync(
@"DELETE FROM LibraryFolder WHERE LibraryPathId = @LibraryPathId AND Path = @Path",
new { LibraryPathId = libraryPath.Id, Path = folder });
}
return Unit.Default;
}
}
}

16
ErsatzTV.Infrastructure/Data/Repositories/MediaCollectionRepository.cs

@ -371,6 +371,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -371,6 +371,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.Include(m => m.MovieMetadata)
.Include(m => m.MediaVersions)
.ThenInclude(mv => mv.Chapters)
.Include(m => m.MediaVersions)
.ThenInclude(mv => mv.MediaFiles)
.Filter(m => movieIds.Contains(m.Id))
.ToListAsync();
@ -395,6 +397,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -395,6 +397,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.Include(m => m.MusicVideoMetadata)
.Include(m => m.MediaVersions)
.ThenInclude(mv => mv.Chapters)
.Include(m => m.MediaVersions)
.ThenInclude(mv => mv.MediaFiles)
.Filter(m => musicVideoIds.Contains(m.Id))
.ToListAsync();
@ -427,6 +431,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -427,6 +431,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.Include(m => m.MusicVideoMetadata)
.Include(m => m.MediaVersions)
.ThenInclude(mv => mv.Chapters)
.Include(m => m.MediaVersions)
.ThenInclude(mv => mv.MediaFiles)
.Filter(m => musicVideoIds.Contains(m.Id))
.ToListAsync();
@ -446,6 +452,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -446,6 +452,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.Include(m => m.OtherVideoMetadata)
.Include(m => m.MediaVersions)
.ThenInclude(mv => mv.Chapters)
.Include(m => m.MediaVersions)
.ThenInclude(mv => mv.MediaFiles)
.Filter(m => otherVideoIds.Contains(m.Id))
.ToListAsync();
@ -465,6 +473,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -465,6 +473,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.Include(m => m.SongMetadata)
.Include(m => m.MediaVersions)
.ThenInclude(mv => mv.Chapters)
.Include(m => m.MediaVersions)
.ThenInclude(mv => mv.MediaFiles)
.Filter(m => songIds.Contains(m.Id))
.ToListAsync();
@ -486,6 +496,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -486,6 +496,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.Include(e => e.EpisodeMetadata)
.Include(e => e.MediaVersions)
.ThenInclude(mv => mv.Chapters)
.Include(m => m.MediaVersions)
.ThenInclude(mv => mv.MediaFiles)
.Include(e => e.Season)
.ThenInclude(s => s.Show)
.ThenInclude(s => s.ShowMetadata)
@ -521,6 +533,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -521,6 +533,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.Include(e => e.EpisodeMetadata)
.Include(e => e.MediaVersions)
.ThenInclude(mv => mv.Chapters)
.Include(m => m.MediaVersions)
.ThenInclude(mv => mv.MediaFiles)
.Include(e => e.Season)
.ThenInclude(s => s.Show)
.ThenInclude(s => s.ShowMetadata)
@ -554,6 +568,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -554,6 +568,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.Include(e => e.EpisodeMetadata)
.Include(e => e.MediaVersions)
.ThenInclude(mv => mv.Chapters)
.Include(m => m.MediaVersions)
.ThenInclude(mv => mv.MediaFiles)
.Include(e => e.Season)
.ThenInclude(s => s.Show)
.ThenInclude(s => s.ShowMetadata)

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

@ -3,6 +3,8 @@ using System.Data; @@ -3,6 +3,8 @@ using System.Data;
using System.Linq;
using System.Threading.Tasks;
using Dapper;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using LanguageExt;
@ -24,5 +26,44 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -24,5 +26,44 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
GROUP BY LanguageCode
ORDER BY COUNT(LanguageCode) DESC")
.Map(result => result.ToList());
public async Task<List<int>> FlagFileNotFound(LibraryPath libraryPath, string path)
{
List<int> ids = await _dbConnection.QueryAsync<int>(
@"SELECT M.Id
FROM MediaItem M
INNER JOIN MediaVersion MV on M.Id = COALESCE(MovieId, MusicVideoId, OtherVideoId, SongId, EpisodeId)
INNER JOIN MediaFile MF on MV.Id = MF.MediaVersionId
WHERE M.LibraryPathId = @LibraryPathId AND MF.Path = @Path",
new { LibraryPathId = libraryPath.Id, Path = path })
.Map(result => result.ToList());
await _dbConnection.ExecuteAsync(
@"UPDATE MediaItem SET State = 1 WHERE Id IN @Ids",
new { Ids = ids });
return ids;
}
public async Task<Unit> FlagNormal(MediaItem mediaItem)
{
mediaItem.State = MediaItemState.Normal;
return await _dbConnection.ExecuteAsync(
@"UPDATE MediaItem SET State = 0 WHERE Id = @Id",
new { mediaItem.Id }).ToUnit();
}
public async Task<Either<BaseError, Unit>> DeleteItems(List<int> mediaItemIds)
{
foreach (int mediaItemId in mediaItemIds)
{
await _dbConnection.ExecuteAsync(
"DELETE FROM MediaItem WHERE Id = @Id",
new { Id = mediaItemId });
}
return Unit.Default;
}
}
}

16
ErsatzTV.Infrastructure/Data/Repositories/MovieRepository.cs

@ -56,6 +56,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -56,6 +56,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.ThenInclude(mm => mm.Writers)
.Include(m => m.MediaVersions)
.ThenInclude(mv => mv.Streams)
.Include(m => m.MediaVersions)
.ThenInclude(mv => mv.MediaFiles)
.OrderBy(m => m.Id)
.SingleOrDefaultAsync(m => m.Id == movieId)
.Map(Optional);
@ -63,7 +65,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -63,7 +65,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
public async Task<Either<BaseError, MediaItemScanResult<Movie>>> GetOrAdd(LibraryPath libraryPath, string path)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
Option<Movie> maybeExisting = await dbContext.Movies
.Include(i => i.MovieMetadata)
.ThenInclude(mm => mm.Artwork)
@ -146,12 +148,13 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -146,12 +148,13 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
public async Task<List<MovieMetadata>> GetMoviesForCards(List<int> ids)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
return await dbContext.MovieMetadata
.AsNoTracking()
.Filter(mm => ids.Contains(mm.MovieId))
.Include(mm => mm.Artwork)
.OrderBy(mm => mm.SortTitle)
.Include(mm => mm.Movie)
.ToListAsync();
}
@ -167,7 +170,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -167,7 +170,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
public async Task<List<int>> DeleteByPath(LibraryPath libraryPath, string path)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
List<int> ids = await _dbConnection.QueryAsync<int>(
@"SELECT M.Id
FROM Movie M
@ -181,13 +184,16 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -181,13 +184,16 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
foreach (int movieId in ids)
{
Movie movie = await dbContext.Movies.FindAsync(movieId);
dbContext.Movies.Remove(movie);
if (movie != null)
{
dbContext.Movies.Remove(movie);
}
}
bool changed = await dbContext.SaveChangesAsync() > 0;
return changed ? ids : new List<int>();
}
public Task<bool> AddGenre(MovieMetadata metadata, Genre genre) =>
_dbConnection.ExecuteAsync(
"INSERT INTO Genre (Name, MovieMetadataId) VALUES (@Name, @MetadataId)",

14
ErsatzTV.Infrastructure/Data/Repositories/MusicVideoRepository.cs

@ -94,18 +94,21 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -94,18 +94,21 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
WHERE MI.LibraryPathId = @LibraryPathId AND MF.Path = @Path",
new { LibraryPathId = libraryPath.Id, Path = path }).Map(result => result.ToList());
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
foreach (int musicVideoId in ids)
{
MusicVideo musicVideo = await dbContext.MusicVideos.FindAsync(musicVideoId);
dbContext.MusicVideos.Remove(musicVideo);
if (musicVideo != null)
{
dbContext.MusicVideos.Remove(musicVideo);
}
}
await dbContext.SaveChangesAsync();
return ids;
}
public Task<bool> AddGenre(MusicVideoMetadata metadata, Genre genre) =>
_dbConnection.ExecuteAsync(
"INSERT INTO Genre (Name, MusicVideoMetadataId) VALUES (@Name, @MetadataId)",
@ -123,7 +126,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -123,7 +126,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
public async Task<List<MusicVideoMetadata>> GetMusicVideosForCards(List<int> ids)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
return await dbContext.MusicVideoMetadata
.AsNoTracking()
.Filter(mvm => ids.Contains(mvm.MusicVideoId))
@ -163,6 +166,9 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -163,6 +166,9 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.Include(m => m.MusicVideo)
.ThenInclude(mv => mv.Artist)
.ThenInclude(a => a.ArtistMetadata)
.Include(m => m.MusicVideo)
.ThenInclude(mv => mv.MediaVersions)
.ThenInclude(mv => mv.MediaFiles)
.Filter(m => m.MusicVideo.ArtistId == artistId)
.OrderBy(m => m.SortTitle)
.Skip((pageNumber - 1) * pageSize)

62
ErsatzTV.Infrastructure/Data/Repositories/TelevisionRepository.cs

@ -45,7 +45,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -45,7 +45,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
public async Task<List<Show>> GetAllShows()
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
return await dbContext.Shows
.AsNoTracking()
.Include(s => s.ShowMetadata)
@ -55,7 +55,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -55,7 +55,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
public async Task<Option<Show>> GetShow(int showId)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
return await dbContext.Shows
.AsNoTracking()
.Filter(s => s.Id == showId)
@ -77,18 +77,19 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -77,18 +77,19 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
public async Task<List<ShowMetadata>> GetShowsForCards(List<int> ids)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
return await dbContext.ShowMetadata
.AsNoTracking()
.Filter(sm => ids.Contains(sm.ShowId))
.Include(sm => sm.Artwork)
.Include(sm => sm.Show)
.OrderBy(sm => sm.SortTitle)
.ToListAsync();
}
public async Task<List<SeasonMetadata>> GetSeasonsForCards(List<int> ids)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
return await dbContext.SeasonMetadata
.AsNoTracking()
.Filter(s => ids.Contains(s.SeasonId))
@ -105,7 +106,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -105,7 +106,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
public async Task<List<EpisodeMetadata>> GetEpisodesForCards(List<int> ids)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
return await dbContext.EpisodeMetadata
.AsNoTracking()
.Filter(em => ids.Contains(em.EpisodeId))
@ -121,13 +122,16 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -121,13 +122,16 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.ThenInclude(s => s.Show)
.ThenInclude(s => s.ShowMetadata)
.ThenInclude(sm => sm.Artwork)
.Include(em => em.Episode)
.ThenInclude(e => e.MediaVersions)
.ThenInclude(mv => mv.MediaFiles)
.OrderBy(em => em.SortTitle)
.ToListAsync();
}
public async Task<List<Season>> GetAllSeasons()
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
return await dbContext.Seasons
.AsNoTracking()
.Include(s => s.SeasonMetadata)
@ -140,7 +144,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -140,7 +144,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
public async Task<Option<Season>> GetSeason(int seasonId)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
return await dbContext.Seasons
.AsNoTracking()
.Include(s => s.SeasonMetadata)
@ -155,7 +159,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -155,7 +159,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
public async Task<int> GetSeasonCount(int showId)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
return await dbContext.Seasons
.AsNoTracking()
.CountAsync(s => s.ShowId == showId);
@ -171,7 +175,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -171,7 +175,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
new { ShowId = televisionShowId })
.Map(results => results.ToList());
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
return await dbContext.Seasons
.AsNoTracking()
.Where(s => showIds.Contains(s.ShowId))
@ -187,7 +191,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -187,7 +191,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
public async Task<int> GetEpisodeCount(int seasonId)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
return await dbContext.Episodes
.AsNoTracking()
.CountAsync(e => e.SeasonId == seasonId);
@ -195,7 +199,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -195,7 +199,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
public async Task<List<EpisodeMetadata>> GetPagedEpisodes(int seasonId, int pageNumber, int pageSize)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
return await dbContext.EpisodeMetadata
.AsNoTracking()
.Filter(em => em.Episode.SeasonId == seasonId)
@ -208,6 +212,9 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -208,6 +212,9 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.ThenInclude(s => s.ShowMetadata)
.ThenInclude(sm => sm.Actors)
.ThenInclude(a => a.Artwork)
.Include(em => em.Episode)
.ThenInclude(e => e.MediaVersions)
.ThenInclude(mv => mv.MediaFiles)
.OrderBy(em => em.EpisodeNumber)
.Skip((pageNumber - 1) * pageSize)
.Take(pageSize)
@ -216,7 +223,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -216,7 +223,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
public async Task<Option<Show>> GetShowByMetadata(int libraryPathId, ShowMetadata metadata)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
Option<int> maybeId = await dbContext.ShowMetadata
.Where(s => s.Title == metadata.Title && s.Year == metadata.Year)
.Where(s => s.Show.LibraryPathId == libraryPathId)
@ -258,7 +265,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -258,7 +265,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
string showFolder,
ShowMetadata metadata)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
try
{
@ -291,7 +298,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -291,7 +298,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
public async Task<Either<BaseError, Season>> GetOrAddSeason(Show show, int libraryPathId, int seasonNumber)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
Option<Season> maybeExisting = await dbContext.Seasons
.Include(s => s.SeasonMetadata)
.ThenInclude(sm => sm.Artwork)
@ -315,7 +322,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -315,7 +322,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
LibraryPath libraryPath,
string path)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
Option<Episode> maybeExisting = await dbContext.Episodes
.Include(i => i.EpisodeMetadata)
.ThenInclude(em => em.Artwork)
@ -387,11 +394,14 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -387,11 +394,14 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
WHERE MI.LibraryPathId = @LibraryPathId AND MF.Path = @Path",
new { LibraryPathId = libraryPath.Id, Path = path });
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
foreach (int episodeId in ids)
{
Episode episode = await dbContext.Episodes.FindAsync(episodeId);
dbContext.Episodes.Remove(episode);
if (episode != null)
{
dbContext.Episodes.Remove(episode);
}
}
await dbContext.SaveChangesAsync();
@ -401,7 +411,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -401,7 +411,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
public async Task<Unit> DeleteEmptySeasons(LibraryPath libraryPath)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
List<Season> seasons = await dbContext.Seasons
.Filter(s => s.LibraryPathId == libraryPath.Id)
.Filter(s => s.Episodes.Count == 0)
@ -413,7 +423,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -413,7 +423,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
public async Task<List<int>> DeleteEmptyShows(LibraryPath libraryPath)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
List<Show> shows = await dbContext.Shows
.Filter(s => s.LibraryPathId == libraryPath.Id)
.Filter(s => s.Seasons.Count == 0)
@ -428,7 +438,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -428,7 +438,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
PlexLibrary library,
PlexShow item)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
Option<PlexShow> maybeExisting = await dbContext.PlexShows
.AsNoTracking()
.Include(i => i.ShowMetadata)
@ -459,7 +469,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -459,7 +469,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
public async Task<Either<BaseError, PlexSeason>> GetOrAddPlexSeason(PlexLibrary library, PlexSeason item)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
Option<PlexSeason> maybeExisting = await dbContext.PlexSeasons
.AsNoTracking()
.Include(i => i.SeasonMetadata)
@ -480,7 +490,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -480,7 +490,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
public async Task<Either<BaseError, PlexEpisode>> GetOrAddPlexEpisode(PlexLibrary library, PlexEpisode item)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
Option<PlexEpisode> maybeExisting = await dbContext.PlexEpisodes
.AsNoTracking()
.Include(i => i.EpisodeMetadata)
@ -577,12 +587,14 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -577,12 +587,14 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
WHERE Show.Id = @ShowId",
new { ShowId = showId });
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
return await dbContext.Episodes
.AsNoTracking()
.Include(e => e.EpisodeMetadata)
.Include(e => e.MediaVersions)
.ThenInclude(mv => mv.Chapters)
.Include(m => m.MediaVersions)
.ThenInclude(mv => mv.MediaFiles)
.Include(e => e.Season)
.ThenInclude(s => s.Show)
.ThenInclude(s => s.ShowMetadata)
@ -592,12 +604,14 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -592,12 +604,14 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
public async Task<List<Episode>> GetSeasonItems(int seasonId)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
return await dbContext.Episodes
.AsNoTracking()
.Include(e => e.EpisodeMetadata)
.Include(e => e.MediaVersions)
.ThenInclude(mv => mv.Chapters)
.Include(m => m.MediaVersions)
.ThenInclude(mv => mv.MediaFiles)
.Include(e => e.Season)
.ThenInclude(s => s.Show)
.ThenInclude(s => s.ShowMetadata)

1
ErsatzTV.Infrastructure/ErsatzTV.Infrastructure.csproj

@ -4,7 +4,6 @@ @@ -4,7 +4,6 @@
<TargetFramework>net6.0</TargetFramework>
<GenerateRuntimeConfigurationFiles>true</GenerateRuntimeConfigurationFiles>
<NoWarn>VSTHRD200</NoWarn>
<DebugType>embedded</DebugType>
</PropertyGroup>
<ItemGroup>

17
ErsatzTV.Infrastructure/Health/Checks/BaseHealthCheck.cs

@ -2,8 +2,8 @@ @@ -2,8 +2,8 @@
using System.Diagnostics;
using System.Threading.Tasks;
using ErsatzTV.Core.Health;
using LanguageExt;
using Lucene.Net.Util;
using static LanguageExt.Prelude;
namespace ErsatzTV.Infrastructure.Health.Checks
{
@ -12,22 +12,25 @@ namespace ErsatzTV.Infrastructure.Health.Checks @@ -12,22 +12,25 @@ namespace ErsatzTV.Infrastructure.Health.Checks
protected abstract string Title { get; }
protected HealthCheckResult Result(HealthCheckStatus status, string message) =>
new(Title, status, message);
new(Title, status, message, None);
protected HealthCheckResult NotApplicableResult() =>
new(Title, HealthCheckStatus.NotApplicable, string.Empty);
new(Title, HealthCheckStatus.NotApplicable, string.Empty, None);
protected HealthCheckResult OkResult() =>
new(Title, HealthCheckStatus.Pass, string.Empty);
new(Title, HealthCheckStatus.Pass, string.Empty, None);
protected HealthCheckResult FailResult(string message) =>
new(Title, HealthCheckStatus.Fail, message);
new(Title, HealthCheckStatus.Fail, message, None);
protected HealthCheckResult WarningResult(string message) =>
new(Title, HealthCheckStatus.Warning, message);
new(Title, HealthCheckStatus.Warning, message, None);
protected HealthCheckResult WarningResult(string message, string link) =>
new(Title, HealthCheckStatus.Warning, message, link);
protected HealthCheckResult InfoResult(string message) =>
new(Title, HealthCheckStatus.Info, message);
new(Title, HealthCheckStatus.Info, message, None);
protected static async Task<string> GetProcessOutput(string path, IEnumerable<string> arguments)
{

2
ErsatzTV.Infrastructure/Health/Checks/FFmpegVersionHealthCheck.cs

@ -66,7 +66,7 @@ namespace ErsatzTV.Infrastructure.Health.Checks @@ -66,7 +66,7 @@ namespace ErsatzTV.Infrastructure.Health.Checks
}
}
return new HealthCheckResult("FFmpeg Version", HealthCheckStatus.Pass, string.Empty);
return new HealthCheckResult("FFmpeg Version", HealthCheckStatus.Pass, string.Empty, None);
}
private Option<HealthCheckResult> ValidateVersion(string version, string app)

76
ErsatzTV.Infrastructure/Health/Checks/FileNotFoundHealthCheck.cs

@ -0,0 +1,76 @@ @@ -0,0 +1,76 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Extensions;
using ErsatzTV.Core.Health;
using ErsatzTV.Core.Health.Checks;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Infrastructure.Health.Checks;
public class FileNotFoundHealthCheck : BaseHealthCheck, IFileNotFoundHealthCheck
{
private readonly IDbContextFactory<TvContext> _dbContextFactory;
public FileNotFoundHealthCheck(IDbContextFactory<TvContext> dbContextFactory) =>
_dbContextFactory = dbContextFactory;
protected override string Title => "File Not Found";
public async Task<HealthCheckResult> Check()
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
List<Episode> episodes = await dbContext.Episodes
.Filter(e => e.State == MediaItemState.FileNotFound)
.Include(e => e.MediaVersions)
.ThenInclude(mv => mv.MediaFiles)
.ToListAsync();
List<Movie> movies = await dbContext.Movies
.Filter(m => m.State == MediaItemState.FileNotFound)
.Include(m => m.MediaVersions)
.ThenInclude(mv => mv.MediaFiles)
.ToListAsync();
List<MusicVideo> musicVideos = await dbContext.MusicVideos
.Filter(mv => mv.State == MediaItemState.FileNotFound)
.Include(mv => mv.MediaVersions)
.ThenInclude(mv => mv.MediaFiles)
.ToListAsync();
List<OtherVideo> otherVideos = await dbContext.OtherVideos
.Filter(ov => ov.State == MediaItemState.FileNotFound)
.Include(ov => ov.MediaVersions)
.ThenInclude(mv => mv.MediaFiles)
.ToListAsync();
List<Song> songs = await dbContext.Songs
.Filter(s => s.State == MediaItemState.FileNotFound)
.Include(s => s.MediaVersions)
.ThenInclude(mv => mv.MediaFiles)
.ToListAsync();
var all = movies.Map(m => m.MediaVersions.Head().MediaFiles.Head().Path)
.Append(episodes.Map(e => e.MediaVersions.Head().MediaFiles.Head().Path))
.Append(musicVideos.Map(mv => mv.GetHeadVersion().MediaFiles.Head().Path))
.Append(otherVideos.Map(ov => ov.GetHeadVersion().MediaFiles.Head().Path))
.Append(songs.Map(s => s.GetHeadVersion().MediaFiles.Head().Path))
.ToList();
if (all.Any())
{
var paths = all.Take(5).ToList();
var files = string.Join(", ", paths);
return WarningResult(
$"There are {all.Count} files that do not exist on disk, including the following: {files}",
$"/search?query=state%3AFileNotFound");
}
return OkResult();
}
}

30
ErsatzTV.Infrastructure/Health/Checks/ZeroDurationHealthCheck.cs

@ -3,6 +3,7 @@ using System.Collections.Generic; @@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Extensions;
using ErsatzTV.Core.Health;
using ErsatzTV.Core.Health.Checks;
using ErsatzTV.Infrastructure.Data;
@ -21,7 +22,7 @@ namespace ErsatzTV.Infrastructure.Health.Checks @@ -21,7 +22,7 @@ namespace ErsatzTV.Infrastructure.Health.Checks
public async Task<HealthCheckResult> Check()
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
List<Episode> episodes = await dbContext.Episodes
.Filter(e => e.MediaVersions.Any(mv => mv.Duration == TimeSpan.Zero))
@ -30,13 +31,34 @@ namespace ErsatzTV.Infrastructure.Health.Checks @@ -30,13 +31,34 @@ namespace ErsatzTV.Infrastructure.Health.Checks
.ToListAsync();
List<Movie> movies = await dbContext.Movies
.Filter(e => e.MediaVersions.Any(mv => mv.Duration == TimeSpan.Zero))
.Include(e => e.MediaVersions)
.Filter(m => m.MediaVersions.Any(mv => mv.Duration == TimeSpan.Zero))
.Include(m => m.MediaVersions)
.ThenInclude(mv => mv.MediaFiles)
.ToListAsync();
List<MusicVideo> musicVideos = await dbContext.MusicVideos
.Filter(mv => mv.MediaVersions.Any(v => v.Duration == TimeSpan.Zero))
.Include(mv => mv.MediaVersions)
.ThenInclude(mv => mv.MediaFiles)
.ToListAsync();
List<OtherVideo> otherVideos = await dbContext.OtherVideos
.Filter(ov => ov.MediaVersions.Any(mv => mv.Duration == TimeSpan.Zero))
.Include(ov => ov.MediaVersions)
.ThenInclude(mv => mv.MediaFiles)
.ToListAsync();
List<string> all = movies.Map(m => m.MediaVersions.Head().MediaFiles.Head().Path)
List<Song> songs = await dbContext.Songs
.Filter(s => s.MediaVersions.Any(mv => mv.Duration == TimeSpan.Zero))
.Include(s => s.MediaVersions)
.ThenInclude(mv => mv.MediaFiles)
.ToListAsync();
var all = movies.Map(m => m.MediaVersions.Head().MediaFiles.Head().Path)
.Append(episodes.Map(e => e.MediaVersions.Head().MediaFiles.Head().Path))
.Append(musicVideos.Map(mv => mv.GetHeadVersion().MediaFiles.Head().Path))
.Append(otherVideos.Map(ov => ov.GetHeadVersion().MediaFiles.Head().Path))
.Append(songs.Map(s => s.GetHeadVersion().MediaFiles.Head().Path))
.ToList();
if (all.Any())

6
ErsatzTV.Infrastructure/Health/HealthCheckService.cs

@ -14,21 +14,23 @@ namespace ErsatzTV.Infrastructure.Health @@ -14,21 +14,23 @@ namespace ErsatzTV.Infrastructure.Health
// ReSharper disable SuggestBaseTypeForParameterInConstructor
public HealthCheckService(
IFFmpegVersionHealthCheck ffmpegVersionHealthCheck,
IFFmpegReportsHealthCheck fFmpegReportsHealthCheck,
IFFmpegReportsHealthCheck ffmpegReportsHealthCheck,
IHardwareAccelerationHealthCheck hardwareAccelerationHealthCheck,
IMovieMetadataHealthCheck movieMetadataHealthCheck,
IEpisodeMetadataHealthCheck episodeMetadataHealthCheck,
IZeroDurationHealthCheck zeroDurationHealthCheck,
IFileNotFoundHealthCheck fileNotFoundHealthCheck,
IVaapiDriverHealthCheck vaapiDriverHealthCheck)
{
_checks = new List<IHealthCheck>
{
ffmpegVersionHealthCheck,
fFmpegReportsHealthCheck,
ffmpegReportsHealthCheck,
hardwareAccelerationHealthCheck,
movieMetadataHealthCheck,
episodeMetadataHealthCheck,
zeroDurationHealthCheck,
fileNotFoundHealthCheck,
vaapiDriverHealthCheck
};
}

3858
ErsatzTV.Infrastructure/Migrations/20211213165714_Add_MediaItemState.Designer.cs generated

File diff suppressed because it is too large Load Diff

26
ErsatzTV.Infrastructure/Migrations/20211213165714_Add_MediaItemState.cs

@ -0,0 +1,26 @@ @@ -0,0 +1,26 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ErsatzTV.Infrastructure.Migrations
{
public partial class Add_MediaItemState : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "State",
table: "MediaItem",
type: "INTEGER",
nullable: false,
defaultValue: 0);
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "State",
table: "MediaItem");
}
}
}

3
ErsatzTV.Infrastructure/Migrations/TvContextModelSnapshot.cs

@ -843,6 +843,9 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -843,6 +843,9 @@ namespace ErsatzTV.Infrastructure.Migrations
b.Property<int>("LibraryPathId")
.HasColumnType("INTEGER");
b.Property<int>("State")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("LibraryPathId");

28
ErsatzTV.Infrastructure/Search/SearchIndex.cs

@ -55,6 +55,7 @@ namespace ErsatzTV.Infrastructure.Search @@ -55,6 +55,7 @@ namespace ErsatzTV.Infrastructure.Search
private const string AlbumField = "album";
private const string MinutesField = "minutes";
private const string ArtistField = "artist";
private const string StateField = "state";
public const string MovieType = "movie";
public const string ShowType = "show";
@ -160,7 +161,8 @@ namespace ErsatzTV.Infrastructure.Search @@ -160,7 +161,8 @@ namespace ErsatzTV.Infrastructure.Search
using var analyzer = new StandardAnalyzer(AppLuceneVersion);
var customAnalyzers = new Dictionary<string, Analyzer>
{
{ ContentRatingField, new KeywordAnalyzer() }
{ ContentRatingField, new KeywordAnalyzer() },
{ StateField, new KeywordAnalyzer() }
};
using var analyzerWrapper = new PerFieldAnalyzerWrapper(analyzer, customAnalyzers);
QueryParser parser = !string.IsNullOrWhiteSpace(searchField)
@ -202,12 +204,18 @@ namespace ErsatzTV.Infrastructure.Search @@ -202,12 +204,18 @@ namespace ErsatzTV.Infrastructure.Search
{
_writer.DeleteAll();
await RebuildItems(searchRepository, itemIds);
_writer.Commit();
return Unit.Default;
}
public async Task<Unit> RebuildItems(ISearchRepository searchRepository, List<int> itemIds)
{
foreach (int id in itemIds)
{
Option<MediaItem> maybeMediaItem = await searchRepository.GetItemToIndex(id);
if (maybeMediaItem.IsSome)
foreach (MediaItem mediaItem in await searchRepository.GetItemToIndex(id))
{
MediaItem mediaItem = maybeMediaItem.ValueUnsafe();
switch (mediaItem)
{
case Movie movie:
@ -238,7 +246,6 @@ namespace ErsatzTV.Infrastructure.Search @@ -238,7 +246,6 @@ namespace ErsatzTV.Infrastructure.Search
}
}
_writer.Commit();
return Unit.Default;
}
@ -306,7 +313,8 @@ namespace ErsatzTV.Infrastructure.Search @@ -306,7 +313,8 @@ namespace ErsatzTV.Infrastructure.Search
new TextField(LibraryNameField, movie.LibraryPath.Library.Name, Field.Store.NO),
new StringField(LibraryIdField, movie.LibraryPath.Library.Id.ToString(), Field.Store.NO),
new StringField(TitleAndYearField, GetTitleAndYear(metadata), Field.Store.NO),
new StringField(JumpLetterField, GetJumpLetter(metadata), Field.Store.YES)
new StringField(JumpLetterField, GetJumpLetter(metadata), Field.Store.YES),
new StringField(StateField, movie.State.ToString(), Field.Store.NO)
};
await AddLanguages(searchRepository, doc, movie.MediaVersions);
@ -632,7 +640,8 @@ namespace ErsatzTV.Infrastructure.Search @@ -632,7 +640,8 @@ namespace ErsatzTV.Infrastructure.Search
new TextField(LibraryNameField, musicVideo.LibraryPath.Library.Name, Field.Store.NO),
new StringField(LibraryIdField, musicVideo.LibraryPath.Library.Id.ToString(), Field.Store.NO),
new StringField(TitleAndYearField, GetTitleAndYear(metadata), Field.Store.NO),
new StringField(JumpLetterField, GetJumpLetter(metadata), Field.Store.YES)
new StringField(JumpLetterField, GetJumpLetter(metadata), Field.Store.YES),
new StringField(StateField, musicVideo.State.ToString(), Field.Store.NO)
};
await AddLanguages(searchRepository, doc, musicVideo.MediaVersions);
@ -720,7 +729,8 @@ namespace ErsatzTV.Infrastructure.Search @@ -720,7 +729,8 @@ namespace ErsatzTV.Infrastructure.Search
new TextField(LibraryNameField, episode.LibraryPath.Library.Name, Field.Store.NO),
new StringField(LibraryIdField, episode.LibraryPath.Library.Id.ToString(), Field.Store.NO),
new StringField(TitleAndYearField, GetTitleAndYear(metadata), Field.Store.NO),
new StringField(JumpLetterField, GetJumpLetter(metadata), Field.Store.YES)
new StringField(JumpLetterField, GetJumpLetter(metadata), Field.Store.YES),
new StringField(StateField, episode.State.ToString(), Field.Store.NO)
};
await AddLanguages(searchRepository, doc, episode.MediaVersions);
@ -808,6 +818,7 @@ namespace ErsatzTV.Infrastructure.Search @@ -808,6 +818,7 @@ namespace ErsatzTV.Infrastructure.Search
new StringField(LibraryIdField, otherVideo.LibraryPath.Library.Id.ToString(), Field.Store.NO),
new StringField(TitleAndYearField, GetTitleAndYear(metadata), Field.Store.NO),
new StringField(JumpLetterField, GetJumpLetter(metadata), Field.Store.YES),
new StringField(StateField, otherVideo.State.ToString(), Field.Store.NO)
};
await AddLanguages(searchRepository, doc, otherVideo.MediaVersions);
@ -851,6 +862,7 @@ namespace ErsatzTV.Infrastructure.Search @@ -851,6 +862,7 @@ namespace ErsatzTV.Infrastructure.Search
new StringField(LibraryIdField, song.LibraryPath.Library.Id.ToString(), Field.Store.NO),
new StringField(TitleAndYearField, GetTitleAndYear(metadata), Field.Store.NO),
new StringField(JumpLetterField, GetJumpLetter(metadata), Field.Store.YES),
new StringField(StateField, song.State.ToString(), Field.Store.NO)
};
await AddLanguages(searchRepository, doc, song.MediaVersions);

1
ErsatzTV/ErsatzTV.csproj

@ -4,7 +4,6 @@ @@ -4,7 +4,6 @@
<TargetFramework>net6.0</TargetFramework>
<NoWarn>VSTHRD200</NoWarn>
<IncludeAllContentForSelfExtract>true</IncludeAllContentForSelfExtract>
<DebugType>embedded</DebugType>
</PropertyGroup>
<ItemGroup>

28
ErsatzTV/Pages/Artist.razor

@ -124,17 +124,31 @@ @@ -124,17 +124,31 @@
<MudContainer MaxWidth="MaxWidth.Large" Class="mt-8">
@foreach (MusicVideoCardViewModel musicVideo in _musicVideos.Cards)
{
<MudCard Class="mb-6">
<MudCard Class="mb-6" Style="display: flex; flex-direction: column">
<div id="@($"music-video-{musicVideo.MusicVideoId}")" style="display: flex; flex-direction: row; scroll-margin-top: 85px">
@if (!string.IsNullOrWhiteSpace(musicVideo.Poster))
{
<MudPaper style="display: flex; flex-direction: column">
<MudPaper style="display: flex; flex-direction: column; position: relative">
<MudCardMedia Image="@($"/artwork/thumbnails/{musicVideo.Poster}")" Style="flex-grow: 1; height: 220px; width: 293px;"/>
@if (musicVideo.State == MediaItemState.FileNotFound)
{
<div style="position: absolute; top: 8px; right: 10px">
<MudIcon Icon="@Icons.Material.Filled.Warning" Color="Color.Error" Size="Size.Large"/>
</div>
}
</MudPaper>
}
else
{
<MudSkeleton SkeletonType="SkeletonType.Rectangle" Animation="Animation.False" Height="220px" Width="293px"/>
<div style="height: 220px; width: 293px; display: flex; position: relative">
<MudSkeleton SkeletonType="SkeletonType.Rectangle" Animation="Animation.False"/>
@if (musicVideo.State == MediaItemState.FileNotFound)
{
<div style="position: absolute; top: 8px; right: 10px">
<MudIcon Icon="@Icons.Material.Filled.Warning" Color="Color.Error" Size="Size.Large"/>
</div>
}
</div>
}
<MudCardContent Class="ml-3">
<div style="display: flex; flex-direction: column; height: 100%">
@ -158,6 +172,14 @@ @@ -158,6 +172,14 @@
</div>
</MudCardContent>
</div>
@if (musicVideo.State == MediaItemState.FileNotFound)
{
<div class="ml-3 mt-3 mb-3" style="display: flex; flex-direction: row; flex-wrap: wrap">
<MudIcon Icon="@Icons.Material.Filled.Warning" Color="Color.Error" Class="mr-2"/>
<MudText>File Not Found:&nbsp;</MudText>
<MudText>@musicVideo.Path</MudText>
</div>
}
</MudCard>
}
</MudContainer>

18
ErsatzTV/Pages/Index.razor

@ -49,7 +49,23 @@ @@ -49,7 +49,23 @@
<div class="ml-2">@context.Title</div>
</div>
</MudTd>
<MudTd DataLabel="Message">@context.Message</MudTd>
<MudTd DataLabel="Message">
@if (context.Link.IsSome)
{
foreach (string link in context.Link)
{
<MudLink Href="@link">
@context.Message
</MudLink>
}
}
else
{
<MudText>
@context.Message
</MudText>
}
</MudTd>
</RowTemplate>
</MudTable>
</MudContainer>

44
ErsatzTV/Pages/Movie.razor

@ -28,18 +28,26 @@ @@ -28,18 +28,26 @@
<div style="display: flex; flex-direction: row;" class="mb-6">
@if (!string.IsNullOrWhiteSpace(_movie.Poster))
{
if (_movie.Poster.StartsWith("http://") || _movie.Poster.StartsWith("https://"))
{
<img class="mud-elevation-2 mr-6"
style="border-radius: 4px; flex-shrink: 0; max-height: 440px;"
src="@_movie.Poster" alt="movie poster"/>
}
else
{
<img class="mud-elevation-2 mr-6"
style="border-radius: 4px; flex-shrink: 0; max-height: 440px;"
src="@($"/artwork/posters/{_movie.Poster}")" alt="movie poster"/>
}
<div class="mr-6" style="display: flex; flex-direction: column; max-height: 440px; position: relative">
@if (_movie.Poster.StartsWith("http://") || _movie.Poster.StartsWith("https://"))
{
<img class="mud-elevation-2"
style="border-radius: 4px; flex-shrink: 0; max-height: 440px;"
src="@_movie.Poster" alt="movie poster"/>
}
else
{
<img class="mud-elevation-2"
style="border-radius: 4px; flex-shrink: 0; max-height: 440px;"
src="@($"/artwork/posters/{_movie.Poster}")" alt="movie poster"/>
}
@if (_movie.MediaItemState == MediaItemState.FileNotFound)
{
<div style="position: absolute; top: 8px; right: 10px">
<MudIcon Icon="@Icons.Material.Filled.Warning" Color="Color.Error" Size="Size.Large"/>
</div>
}
</div>
}
<div style="display: flex; flex-direction: column; height: 100%">
<MudText Typo="Typo.h2" Class="media-item-title">@_movie.Title</MudText>
@ -62,6 +70,18 @@ @@ -62,6 +70,18 @@
</div>
</div>
</div>
@if (_movie.MediaItemState == MediaItemState.FileNotFound)
{
<MudCard Class="mb-6">
<MudCardContent>
<div style="display: flex; flex-direction: row; flex-wrap: wrap">
<MudIcon Icon="@Icons.Material.Filled.Warning" Color="Color.Error" Class="mr-2"/>
<MudText>File Not Found:&nbsp;</MudText>
<MudText>@_movie.Path</MudText>
</div>
</MudCardContent>
</MudCard>
}
<MudCard Class="mb-6">
<MudCardContent>
@if (_sortedContentRatings.Any())

2
ErsatzTV/Pages/MultiSelectBase.cs

@ -42,6 +42,8 @@ namespace ErsatzTV.Pages @@ -42,6 +42,8 @@ namespace ErsatzTV.Pages
[Inject]
protected IMediator Mediator { get; set; }
protected System.Collections.Generic.HashSet<MediaCardViewModel> SelectedItems => _selectedItems;
protected bool IsSelected(MediaCardViewModel card) =>
_selectedItems.Contains(card);

32
ErsatzTV/Pages/TelevisionEpisodeList.razor

@ -78,18 +78,22 @@ @@ -78,18 +78,22 @@
<div id="@($"episode-{episode.EpisodeId}")" style="display: flex; flex-direction: row; scroll-margin-top: 85px">
@if (!string.IsNullOrWhiteSpace(episode.Poster))
{
if (episode.Poster.StartsWith("http://") || episode.Poster.StartsWith("https://"))
{
<MudPaper style="display: flex; flex-direction: column">
<MudPaper style="display: flex; flex-direction: column; position: relative">
@if (episode.Poster.StartsWith("http://") || episode.Poster.StartsWith("https://"))
{
<MudCardMedia Image="@episode.Poster" Style="flex-grow: 1; height: 220px; width: 392px;"/>
</MudPaper>
}
else
{
<MudPaper style="display: flex; flex-direction: column">
}
else
{
<MudCardMedia Image="@($"/artwork/thumbnails/{episode.Poster}")" Style="flex-grow: 1; height: 220px; width: 392px;"/>
</MudPaper>
}
}
@if (episode.State == MediaItemState.FileNotFound)
{
<div style="position: absolute; top: 8px; right: 10px">
<MudIcon Icon="@Icons.Material.Filled.Warning" Color="Color.Error" Size="Size.Large"/>
</div>
}
</MudPaper>
}
<MudCardContent Class="ml-3">
<div style="display: flex; flex-direction: column; height: 100%">
@ -107,6 +111,14 @@ @@ -107,6 +111,14 @@
</MudCardContent>
</div>
<div class="pl-3 pt-3">
@if (episode.State == MediaItemState.FileNotFound)
{
<div class="mb-3" style="display: flex; flex-direction: row; flex-wrap: wrap">
<MudIcon Icon="@Icons.Material.Filled.Warning" Color="Color.Error" Class="mr-2"/>
<MudText>File Not Found:&nbsp;</MudText>
<MudText>@episode.Path</MudText>
</div>
}
<div style="display: flex; flex-direction: row; flex-wrap: wrap">
<MudText GutterBottom="true">Released: @episode.Aired.ToShortDateString()</MudText>
</div>

564
ErsatzTV/Pages/Trash.razor

@ -0,0 +1,564 @@ @@ -0,0 +1,564 @@
@page "/media/trash"
@using ErsatzTV.Application.MediaCards
@using ErsatzTV.Application.Search.Queries
@using ErsatzTV.Extensions
@using Unit = LanguageExt.Unit
@using ErsatzTV.Application.Maintenance.Commands
@inherits MultiSelectBase<Search>
@inject NavigationManager _navigationManager
@inject ChannelWriter<IBackgroundServiceRequest> _channel
<MudPaper Square="true" Style="display: flex; height: 64px; left: 240px; padding: 0; position: fixed; right: 0; z-index: 100;">
<div style="display: flex; flex-direction: row; margin-bottom: auto; margin-top: auto; width: 100%" class="ml-6 mr-6">
@if (IsSelectMode())
{
<MudText Typo="Typo.h6" Color="Color.Primary">@SelectionLabel()</MudText>
<div style="margin-left: auto">
<MudButton Variant="Variant.Filled"
Color="Color.Error"
StartIcon="@Icons.Material.Filled.Delete"
OnClick="@(_ => DeleteFromDatabase())">
Delete From Database
</MudButton>
<MudButton Class="ml-3"
Variant="Variant.Filled"
Color="Color.Secondary"
StartIcon="@Icons.Material.Filled.Check"
OnClick="@(_ => ClearSelection())">
Clear Selection
</MudButton>
</div>
}
else
{
if (_movies?.Count > 0)
{
<MudLink Class="ml-4" Href="@(_navigationManager.Uri.Split("#").Head() + "#movies")" Style="margin-bottom: auto; margin-top: auto">@_movies.Count Movies</MudLink>
}
if (_shows?.Count > 0)
{
<MudLink Class="ml-4" Href="@(_navigationManager.Uri.Split("#").Head() + "#shows")" Style="margin-bottom: auto; margin-top: auto">@_shows.Count Shows</MudLink>
}
if (_seasons?.Count > 0)
{
<MudLink Class="ml-4" Href="@(_navigationManager.Uri.Split("#").Head() + "#seasons")" Style="margin-bottom: auto; margin-top: auto">@_seasons.Count Seasons</MudLink>
}
if (_episodes?.Count > 0)
{
<MudLink Class="ml-4" Href="@(_navigationManager.Uri.Split("#").Head() + "#episodes")" Style="margin-bottom: auto; margin-top: auto">@_episodes.Count Episodes</MudLink>
}
if (_artists?.Count > 0)
{
<MudLink Class="ml-4" Href="@(_navigationManager.Uri.Split("#").Head() + "#artists")" Style="margin-bottom: auto; margin-top: auto">@_artists.Count Artists</MudLink>
}
if (_musicVideos?.Count > 0)
{
<MudLink Class="ml-4" Href="@(_navigationManager.Uri.Split("#").Head() + "#music_videos")" Style="margin-bottom: auto; margin-top: auto">@_musicVideos.Count Music Videos</MudLink>
}
if (_otherVideos?.Count > 0)
{
<MudLink Class="ml-4" Href="@(_navigationManager.Uri.Split("#").Head() + "#other_videos")" Style="margin-bottom: auto; margin-top: auto">@_otherVideos.Count Other Videos</MudLink>
}
if (_songs?.Count > 0)
{
<MudLink Class="ml-4" Href="@(_navigationManager.Uri.Split("#").Head() + "#songs")" Style="margin-bottom: auto; margin-top: auto">@_songs.Count Songs</MudLink>
}
if (_movies?.Count == 0 && _shows?.Count == 0 && _seasons?.Count == 0 && _episodes?.Count == 0 && _artists?.Count == 0 && _musicVideos?.Count == 0 && _otherVideos?.Count == 0 && _songs?.Count == 0)
{
<MudText>Nothing to see here...</MudText>
}
}
</div>
</MudPaper>
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Style="margin-top: 96px">
@if (_movies?.Count > 0)
{
<div class="mb-4" style="align-items: baseline; display: flex; flex-direction: row;">
<MudText Typo="Typo.h4"
Style="scroll-margin-top: 160px"
UserAttributes="@(new Dictionary<string, object> { { "id", "movies" } })">
Movies
</MudText>
@if (_movies.Count > 50)
{
<MudLink Href="@GetMoviesLink()" Class="ml-4">See All >></MudLink>
}
</div>
<MudContainer MaxWidth="MaxWidth.False" Class="media-card-grid">
@foreach (MovieCardViewModel card in _movies.Cards.OrderBy(m => m.SortTitle))
{
<MediaCard Data="@card"
Link="@($"/media/movies/{card.MovieId}")"
DeleteClicked="@DeleteItemFromDatabase"
SelectColor="@Color.Error"
SelectClicked="@(e => SelectClicked(card, e))"
IsSelected="@IsSelected(card)"
IsSelectMode="@IsSelectMode()"/>
}
</MudContainer>
}
@if (_shows?.Count > 0)
{
<div class="mb-4" style="align-items: baseline; display: flex; flex-direction: row;">
<MudText Typo="Typo.h4"
Style="scroll-margin-top: 160px"
UserAttributes="@(new Dictionary<string, object> { { "id", "shows" } })">
Shows
</MudText>
@if (_shows.Count > 50)
{
<MudLink Href="@GetShowsLink()" Class="ml-4">See All >></MudLink>
}
</div>
<MudContainer MaxWidth="MaxWidth.False" Class="media-card-grid">
@foreach (TelevisionShowCardViewModel card in _shows.Cards.OrderBy(s => s.SortTitle))
{
<MediaCard Data="@card"
Link="@($"/media/tv/shows/{card.TelevisionShowId}")"
DeleteClicked="@DeleteItemFromDatabase"
SelectColor="@Color.Error"
SelectClicked="@(e => SelectClicked(card, e))"
IsSelected="@IsSelected(card)"
IsSelectMode="@IsSelectMode()"/>
}
</MudContainer>
}
@if (_seasons?.Count > 0)
{
<div class="mb-4" style="align-items: baseline; display: flex; flex-direction: row;">
<MudText Typo="Typo.h4"
Style="scroll-margin-top: 160px"
UserAttributes="@(new Dictionary<string, object> { { "id", "seasons" } })">
Seasons
</MudText>
@if (_seasons.Count > 50)
{
<MudLink Href="@GetSeasonsLink()" Class="ml-4">See All >></MudLink>
}
</div>
<MudContainer MaxWidth="MaxWidth.False" Class="media-card-grid">
@foreach (TelevisionSeasonCardViewModel card in _seasons.Cards.OrderBy(s => s.SortTitle))
{
<MediaCard Data="@card"
Link="@($"/media/tv/seasons/{card.TelevisionSeasonId}")"
DeleteClicked="@DeleteItemFromDatabase"
SelectColor="@Color.Error"
SelectClicked="@(e => SelectClicked(card, e))"
IsSelected="@IsSelected(card)"
IsSelectMode="@IsSelectMode()"/>
}
</MudContainer>
}
@if (_episodes?.Count > 0)
{
<div class="mb-4" style="align-items: baseline; display: flex; flex-direction: row;">
<MudText Typo="Typo.h4"
Style="scroll-margin-top: 160px"
UserAttributes="@(new Dictionary<string, object> { { "id", "episodes" } })">
Episodes
</MudText>
@if (_episodes.Count > 50)
{
<MudLink Href="@GetEpisodesLink()" Class="ml-4">See All >></MudLink>
}
</div>
<MudContainer MaxWidth="MaxWidth.False" Class="media-card-grid">
@foreach (TelevisionEpisodeCardViewModel card in _episodes.Cards.OrderBy(s => s.SortTitle))
{
<MediaCard Data="@card"
DeleteClicked="@DeleteItemFromDatabase"
SelectColor="@Color.Error"
Link="@($"/media/tv/seasons/{card.SeasonId}#episode-{card.EpisodeId}")"
Subtitle="@($"{card.ShowTitle} - S{card.Season} E{card.Episode}")"
SelectClicked="@(e => SelectClicked(card, e))"
IsSelected="@IsSelected(card)"
IsSelectMode="@IsSelectMode()"/>
}
</MudContainer>
}
@if (_artists?.Count > 0)
{
<div class="mb-4" style="align-items: baseline; display: flex; flex-direction: row;">
<MudText Typo="Typo.h4"
Style="scroll-margin-top: 160px"
UserAttributes="@(new Dictionary<string, object> { { "id", "artists" } })">
Artists
</MudText>
@if (_artists.Count > 50)
{
<MudLink Href="@GetArtistsLink()" Class="ml-4">See All >></MudLink>
}
</div>
<MudContainer MaxWidth="MaxWidth.False" Class="media-card-grid">
@foreach (ArtistCardViewModel card in _artists.Cards.OrderBy(s => s.SortTitle))
{
<MediaCard Data="@card"
Link="@($"/media/music/artists/{card.ArtistId}")"
ArtworkKind="ArtworkKind.Thumbnail"
DeleteClicked="@DeleteItemFromDatabase"
SelectColor="@Color.Error"
SelectClicked="@(e => SelectClicked(card, e))"
IsSelected="@IsSelected(card)"
IsSelectMode="@IsSelectMode()"/>
}
</MudContainer>
}
@if (_musicVideos?.Count > 0)
{
<div class="mb-4" style="align-items: baseline; display: flex; flex-direction: row;">
<MudText Typo="Typo.h4"
Style="scroll-margin-top: 160px"
UserAttributes="@(new Dictionary<string, object> { { "id", "music_videos" } })">
Music Videos
</MudText>
@if (_musicVideos.Count > 50)
{
<MudLink Href="@GetMusicVideosLink()" Class="ml-4">See All >></MudLink>
}
</div>
<MudContainer MaxWidth="MaxWidth.False" Class="media-card-grid">
@foreach (MusicVideoCardViewModel card in _musicVideos.Cards.OrderBy(s => s.SortTitle))
{
<MediaCard Data="@card"
Link=""
ArtworkKind="ArtworkKind.Thumbnail"
DeleteClicked="@DeleteItemFromDatabase"
SelectColor="@Color.Error"
SelectClicked="@(e => SelectClicked(card, e))"
IsSelected="@IsSelected(card)"
IsSelectMode="@IsSelectMode()"/>
}
</MudContainer>
}
@if (_otherVideos?.Count > 0)
{
<div class="mb-4" style="align-items: baseline; display: flex; flex-direction: row;">
<MudText Typo="Typo.h4"
Style="scroll-margin-top: 160px"
UserAttributes="@(new Dictionary<string, object> { { "id", "other_videos" } })">
Other Videos
</MudText>
@if (_otherVideos.Count > 50)
{
<MudLink Href="@GetOtherVideosLink()" Class="ml-4">See All >></MudLink>
}
</div>
<MudContainer MaxWidth="MaxWidth.False" Class="media-card-grid">
@foreach (OtherVideoCardViewModel card in _otherVideos.Cards.OrderBy(s => s.SortTitle))
{
<MediaCard Data="@card"
Link=""
ArtworkKind="ArtworkKind.Thumbnail"
DeleteClicked="@DeleteItemFromDatabase"
SelectColor="@Color.Error"
SelectClicked="@(e => SelectClicked(card, e))"
IsSelected="@IsSelected(card)"
IsSelectMode="@IsSelectMode()"/>
}
</MudContainer>
}
@if (_songs?.Count > 0)
{
<div class="mb-4" style="align-items: baseline; display: flex; flex-direction: row;">
<MudText Typo="Typo.h4"
Style="scroll-margin-top: 160px"
UserAttributes="@(new Dictionary<string, object> { { "id", "songs" } })">
Songs
</MudText>
@if (_songs.Count > 50)
{
<MudLink Href="@GetSongsLink()" Class="ml-4">See All >></MudLink>
}
</div>
<MudContainer MaxWidth="MaxWidth.False" Class="media-card-grid">
@foreach (SongCardViewModel card in _songs.Cards.OrderBy(s => s.SortTitle))
{
<MediaCard Data="@card"
Link=""
ArtworkKind="ArtworkKind.Thumbnail"
DeleteClicked="@DeleteItemFromDatabase"
SelectColor="@Color.Error"
SelectClicked="@(e => SelectClicked(card, e))"
IsSelected="@IsSelected(card)"
IsSelectMode="@IsSelectMode()"/>
}
</MudContainer>
}
</MudContainer>
@code {
private string _query;
private MovieCardResultsViewModel _movies;
private TelevisionShowCardResultsViewModel _shows;
private TelevisionSeasonCardResultsViewModel _seasons;
private TelevisionEpisodeCardResultsViewModel _episodes;
private MusicVideoCardResultsViewModel _musicVideos;
private OtherVideoCardResultsViewModel _otherVideos;
private SongCardResultsViewModel _songs;
private ArtistCardResultsViewModel _artists;
protected override Task OnInitializedAsync() => RefreshData();
protected override async Task RefreshData()
{
_query = "state:FileNotFound";
if (!string.IsNullOrWhiteSpace(_query))
{
_movies = await Mediator.Send(new QuerySearchIndexMovies($"type:movie AND ({_query})", 1, 50));
_shows = await Mediator.Send(new QuerySearchIndexShows($"type:show AND ({_query})", 1, 50));
_seasons = await Mediator.Send(new QuerySearchIndexSeasons($"type:season AND ({_query})", 1, 50));
_episodes = await Mediator.Send(new QuerySearchIndexEpisodes($"type:episode AND ({_query})", 1, 50));
_musicVideos = await Mediator.Send(new QuerySearchIndexMusicVideos($"type:music_video AND ({_query})", 1, 50));
_otherVideos = await Mediator.Send(new QuerySearchIndexOtherVideos($"type:other_video AND ({_query})", 1, 50));
_songs = await Mediator.Send(new QuerySearchIndexSongs($"type:song AND ({_query})", 1, 50));
_artists = await Mediator.Send(new QuerySearchIndexArtists($"type:artist AND ({_query})", 1, 50));
}
}
private void SelectClicked(MediaCardViewModel card, MouseEventArgs e)
{
List<MediaCardViewModel> GetSortedItems()
{
return _movies.Cards.OrderBy(m => m.SortTitle)
.Append<MediaCardViewModel>(_shows.Cards.OrderBy(s => s.SortTitle))
.Append(_seasons.Cards.OrderBy(s => s.SortTitle))
.Append(_episodes.Cards.OrderBy(ep => ep.SortTitle))
.Append(_artists.Cards.OrderBy(a => a.SortTitle))
.Append(_musicVideos.Cards.OrderBy(mv => mv.SortTitle))
.Append(_otherVideos.Cards.OrderBy(ov => ov.SortTitle))
.Append(_songs.Cards.OrderBy(ov => ov.SortTitle))
.ToList();
}
SelectClicked(GetSortedItems, card, e);
}
private string GetMoviesLink()
{
var uri = "/media/movies/page/1";
if (!string.IsNullOrWhiteSpace(_query))
{
(string key, string value) = _query.EncodeQuery();
uri = $"{uri}?{key}={value}";
}
return uri;
}
private string GetShowsLink()
{
var uri = "/media/tv/shows/page/1";
if (!string.IsNullOrWhiteSpace(_query))
{
(string key, string value) = _query.EncodeQuery();
uri = $"{uri}?{key}={value}";
}
return uri;
}
private string GetSeasonsLink()
{
var uri = "/media/tv/seasons/page/1";
if (!string.IsNullOrWhiteSpace(_query))
{
(string key, string value) = _query.EncodeQuery();
uri = $"{uri}?{key}={value}";
}
return uri;
}
private string GetEpisodesLink()
{
var uri = "/media/tv/episodes/page/1";
if (!string.IsNullOrWhiteSpace(_query))
{
(string key, string value) = _query.EncodeQuery();
uri = $"{uri}?{key}={value}";
}
return uri;
}
private string GetArtistsLink()
{
var uri = "/media/music/artists/page/1";
if (!string.IsNullOrWhiteSpace(_query))
{
(string key, string value) = _query.EncodeQuery();
uri = $"{uri}?{key}={value}";
}
return uri;
}
private string GetMusicVideosLink()
{
var uri = "/media/music/videos/page/1";
if (!string.IsNullOrWhiteSpace(_query))
{
(string key, string value) = _query.EncodeQuery();
uri = $"{uri}?{key}={value}";
}
return uri;
}
private string GetOtherVideosLink()
{
var uri = "/media/other/videos/page/1";
if (!string.IsNullOrWhiteSpace(_query))
{
(string key, string value) = _query.EncodeQuery();
uri = $"{uri}?{key}={value}";
}
return uri;
}
private string GetSongsLink()
{
var uri = "/media/music/songs/page/1";
if (!string.IsNullOrWhiteSpace(_query))
{
(string key, string value) = _query.EncodeQuery();
uri = $"{uri}?{key}={value}";
}
return uri;
}
private Task DeleteFromDatabase() => DeleteItemsFromDatabase(
SelectedItems.OfType<MovieCardViewModel>().Map(m => m.MovieId).ToList(),
SelectedItems.OfType<TelevisionShowCardViewModel>().Map(s => s.TelevisionShowId).ToList(),
SelectedItems.OfType<TelevisionSeasonCardViewModel>().Map(s => s.TelevisionSeasonId).ToList(),
SelectedItems.OfType<TelevisionEpisodeCardViewModel>().Map(e => e.EpisodeId).ToList(),
SelectedItems.OfType<ArtistCardViewModel>().Map(a => a.ArtistId).ToList(),
SelectedItems.OfType<MusicVideoCardViewModel>().Map(mv => mv.MusicVideoId).ToList(),
SelectedItems.OfType<OtherVideoCardViewModel>().Map(ov => ov.OtherVideoId).ToList(),
SelectedItems.OfType<SongCardViewModel>().Map(s => s.SongId).ToList());
private async Task DeleteItemsFromDatabase(
List<int> movieIds,
List<int> showIds,
List<int> seasonIds,
List<int> episodeIds,
List<int> artistIds,
List<int> musicVideoIds,
List<int> otherVideoIds,
List<int> songIds,
string entityName = "selected items")
{
int count = movieIds.Count + showIds.Count + seasonIds.Count + episodeIds.Count + artistIds.Count +
musicVideoIds.Count + otherVideoIds.Count + songIds.Count;
var parameters = new DialogParameters
{ { "EntityType", count.ToString() }, { "EntityName", entityName } };
var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall };
IDialogReference dialog = Dialog.Show<DeleteFromDatabaseDialog>("Delete From Database", parameters, options);
DialogResult result = await dialog.Result;
if (!result.Cancelled)
{
var request = new DeleteItemsFromDatabase(
movieIds.Append(showIds)
.Append(seasonIds)
.Append(episodeIds)
.Append(artistIds)
.Append(musicVideoIds)
.Append(otherVideoIds)
.Append(songIds)
.ToList());
Either<BaseError, Unit> addResult = await Mediator.Send(request);
await addResult.Match(
Left: error =>
{
Snackbar.Add($"Unexpected error deleting items from database: {error.Value}");
Logger.LogError("Unexpected error deleting items from database: {Error}", error.Value);
return Task.CompletedTask;
},
Right: async _ =>
{
Snackbar.Add($"Deleted {count} items from the database", Severity.Success);
ClearSelection();
await RefreshData();
});
}
}
private async Task DeleteItemFromDatabase(MediaCardViewModel vm)
{
DeleteItemsFromDatabase request;
switch (vm)
{
case MovieCardViewModel movie:
request = new DeleteItemsFromDatabase(new List<int> { movie.MovieId });
await DeleteItemsWithConfirmation("movie", $"{movie.Title} ({movie.Subtitle})", request);
break;
case TelevisionShowCardViewModel show:
request = new DeleteItemsFromDatabase(new List<int> { show.TelevisionShowId });
await DeleteItemsWithConfirmation("show", $"{show.Title} ({show.Subtitle})", request);
break;
case TelevisionSeasonCardViewModel season:
request = new DeleteItemsFromDatabase(new List<int> { season.TelevisionSeasonId });
await DeleteItemsWithConfirmation("season", $"{season.Title} ({season.Subtitle})", request);
break;
case TelevisionEpisodeCardViewModel episode:
request = new DeleteItemsFromDatabase(new List<int> { episode.EpisodeId });
await DeleteItemsWithConfirmation("episode", $"{episode.Title} ({episode.Subtitle})", request);
break;
case ArtistCardViewModel artist:
request = new DeleteItemsFromDatabase(new List<int> { artist.ArtistId });
await DeleteItemsWithConfirmation("artist", $"{artist.Title} ({artist.Subtitle})", request);
break;
case MusicVideoCardViewModel musicVideo:
request = new DeleteItemsFromDatabase(new List<int> { musicVideo.MusicVideoId });
await DeleteItemsWithConfirmation("music video", $"{musicVideo.Title} ({musicVideo.Subtitle})", request);
break;
case OtherVideoCardViewModel otherVideo:
request = new DeleteItemsFromDatabase(new List<int> { otherVideo.OtherVideoId });
await DeleteItemsWithConfirmation("other video", $"{otherVideo.Title} ({otherVideo.Subtitle})", request);
break;
case SongCardViewModel song:
request = new DeleteItemsFromDatabase(new List<int> { song.SongId });
await DeleteItemsWithConfirmation("song", $"{song.Title} ({song.Subtitle})", request);
break;
}
}
private async Task DeleteItemsWithConfirmation(
string entityType,
string entityName,
DeleteItemsFromDatabase request)
{
var parameters = new DialogParameters { { "EntityType", entityType }, { "EntityName", entityName } };
var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall };
IDialogReference dialog = Dialog.Show<DeleteFromDatabaseDialog>("Delete From Database", parameters, options);
DialogResult result = await dialog.Result;
if (!result.Cancelled)
{
await Mediator.Send(request);
await RefreshData();
}
}
}

2
ErsatzTV/Shared/AddToCollectionDialog.razor

@ -55,7 +55,7 @@ @@ -55,7 +55,7 @@
[Parameter]
public string DetailHighlight { get; set; }
private readonly MediaCollectionViewModel _newCollection = new(-1, "(New Collection)", false);
private readonly MediaCollectionViewModel _newCollection = new(-1, "(New Collection)", false, MediaItemState.Normal);
private string _newCollectionName;
private List<MediaCollectionViewModel> _collections;

53
ErsatzTV/Shared/DeleteFromDatabaseDialog.razor

@ -0,0 +1,53 @@ @@ -0,0 +1,53 @@
@inject IMediator _mediator
<div @onkeydown="@OnKeyDown">
<MudDialog>
<DialogContent>
<MudContainer Class="mb-6">
<MudHighlighter Class="mud-primary-text"
Style="background-color: transparent; font-weight: bold"
Text="@FormatText()"
HighlightedText="@EntityName"/>
</MudContainer>
</DialogContent>
<DialogActions>
<MudButton OnClick="Cancel">Cancel</MudButton>
<MudButton Color="Color.Error" Variant="Variant.Filled" OnClick="Submit">
Delete From Database
</MudButton>
</DialogActions>
</MudDialog>
</div>
@code {
[CascadingParameter]
MudDialogInstance MudDialog { get; set; }
[Parameter]
public string EntityType { get; set; }
[Parameter]
public string EntityName { get; set; }
[Parameter]
public string DetailText { get; set; }
[Parameter]
public string DetailHighlight { get; set; }
private string FormatText() => $"Do you really want to delete the {EntityType} {EntityName} from the database?";
private void Submit() => MudDialog.Close(DialogResult.Ok(true));
private void Cancel() => MudDialog.Cancel();
private void OnKeyDown(KeyboardEventArgs e)
{
if (e.Code is "Enter" or "NumpadEnter")
{
Submit();
}
}
}

1
ErsatzTV/Shared/MainLayout.razor

@ -50,6 +50,7 @@ @@ -50,6 +50,7 @@
</MudNavGroup>
<MudNavGroup Title="Media" Expanded="true">
<MudNavLink Href="/media/libraries">Libraries</MudNavLink>
<MudNavLink Href="/media/trash">Trash</MudNavLink>
<MudNavLink Href="/media/tv/shows">TV Shows</MudNavLink>
<MudNavLink Href="/media/movies">Movies</MudNavLink>
<MudNavLink Href="/media/music/artists">Music</MudNavLink>

6
ErsatzTV/Shared/MediaCard.razor

@ -24,6 +24,12 @@ @@ -24,6 +24,12 @@
Style="margin: auto"/>
</div>
}
@if (Data.State == MediaItemState.FileNotFound)
{
<div style="position: absolute; top: 12px; right: 12px">
<MudIcon Icon="@Icons.Material.Filled.Warning" Color="Color.Error"/>
</div>
}
<div class="media-card-overlay" style="">
<MudButton Link="@(IsSelectMode ? null : Link)"
Style="height: 100%; width: 100%"

1
ErsatzTV/Startup.cs

@ -266,6 +266,7 @@ namespace ErsatzTV @@ -266,6 +266,7 @@ namespace ErsatzTV
services.AddScoped<IMovieMetadataHealthCheck, MovieMetadataHealthCheck>();
services.AddScoped<IEpisodeMetadataHealthCheck, EpisodeMetadataHealthCheck>();
services.AddScoped<IZeroDurationHealthCheck, ZeroDurationHealthCheck>();
services.AddScoped<IFileNotFoundHealthCheck, FileNotFoundHealthCheck>();
services.AddScoped<IVaapiDriverHealthCheck, VaapiDriverHealthCheck>();
services.AddScoped<IHealthCheckService, HealthCheckService>();

4
ErsatzTV/ViewModels/MultiCollectionSmartItemEditViewModel.cs

@ -1,4 +1,5 @@ @@ -1,4 +1,5 @@
using ErsatzTV.Application.MediaCollections;
using ErsatzTV.Core.Domain;
namespace ErsatzTV.ViewModels
{
@ -16,7 +17,8 @@ namespace ErsatzTV.ViewModels @@ -16,7 +17,8 @@ namespace ErsatzTV.ViewModels
Collection = new MediaCollectionViewModel(
_smartCollection.Id,
_smartCollection.Name,
false);
false,
MediaItemState.Normal);
}
}

2
docker/Dockerfile

@ -28,7 +28,7 @@ COPY ErsatzTV.Core.Tests/. ./ErsatzTV.Core.Tests/ @@ -28,7 +28,7 @@ COPY ErsatzTV.Core.Tests/. ./ErsatzTV.Core.Tests/
COPY ErsatzTV.Infrastructure/. ./ErsatzTV.Infrastructure/
WORKDIR /source/ErsatzTV
ARG INFO_VERSION="unknown"
RUN dotnet publish -c release -o /app -r linux-x64 --self-contained false --no-restore /p:InformationalVersion=${INFO_VERSION}
RUN dotnet publish -c release -o /app -r linux-x64 --self-contained false --no-restore /p:DebugType=Embedded /p:InformationalVersion=${INFO_VERSION}
# final stage/image
FROM runtime-base

2
docker/nvidia/Dockerfile

@ -22,7 +22,7 @@ COPY ErsatzTV.Core.Tests/. ./ErsatzTV.Core.Tests/ @@ -22,7 +22,7 @@ COPY ErsatzTV.Core.Tests/. ./ErsatzTV.Core.Tests/
COPY ErsatzTV.Infrastructure/. ./ErsatzTV.Infrastructure/
WORKDIR /source/ErsatzTV
ARG INFO_VERSION="unknown"
RUN dotnet publish -c release -o /app -r linux-x64 --self-contained false --no-restore /p:InformationalVersion=${INFO_VERSION}
RUN dotnet publish -c release -o /app -r linux-x64 --self-contained false --no-restore /p:DebugType=Embedded /p:InformationalVersion=${INFO_VERSION}
# final stage/image
FROM jasongdove/ffmpeg:4.4-nvidia2004 AS runtime-base

2
docker/vaapi/Dockerfile

@ -22,7 +22,7 @@ COPY ErsatzTV.Core.Tests/. ./ErsatzTV.Core.Tests/ @@ -22,7 +22,7 @@ COPY ErsatzTV.Core.Tests/. ./ErsatzTV.Core.Tests/
COPY ErsatzTV.Infrastructure/. ./ErsatzTV.Infrastructure/
WORKDIR /source/ErsatzTV
ARG INFO_VERSION="unknown"
RUN dotnet publish -c release -o /app -r linux-x64 --self-contained false --no-restore /p:InformationalVersion=${INFO_VERSION}
RUN dotnet publish -c release -o /app -r linux-x64 --self-contained false --no-restore /p:DebugType=Embedded /p:InformationalVersion=${INFO_VERSION}
# final stage/image
FROM jasongdove/ffmpeg:4.4-vaapi2004 AS runtime-base

Loading…
Cancel
Save