Browse Source

add other videos library kind (#429)

pull/430/head
Jason Dove 4 years ago committed by GitHub
parent
commit
0daeb844b9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 6
      CHANGELOG.md
  2. 18
      ErsatzTV.Application/Libraries/Commands/UpdateLocalLibraryHandler.cs
  3. 3
      ErsatzTV.Application/MediaCards/CollectionCardResultsViewModel.cs
  4. 9
      ErsatzTV.Application/MediaCards/Mapper.cs
  5. 11
      ErsatzTV.Application/MediaCards/OtherVideoCardResultsViewModel.cs
  6. 17
      ErsatzTV.Application/MediaCards/OtherVideoCardViewModel.cs
  7. 3
      ErsatzTV.Application/MediaCards/Queries/GetCollectionCardsHandler.cs
  8. 3
      ErsatzTV.Application/MediaCollections/Commands/AddItemsToCollection.cs
  9. 1
      ErsatzTV.Application/MediaCollections/Commands/AddItemsToCollectionHandler.cs
  10. 8
      ErsatzTV.Application/MediaCollections/Commands/AddOtherVideoToCollection.cs
  11. 80
      ErsatzTV.Application/MediaCollections/Commands/AddOtherVideoToCollectionHandler.cs
  12. 10
      ErsatzTV.Application/MediaSources/Commands/ScanLocalLibraryHandler.cs
  13. 4
      ErsatzTV.Application/Playouts/Mapper.cs
  14. 4
      ErsatzTV.Application/Playouts/Queries/GetFuturePlayoutItemsByIdHandler.cs
  15. 14
      ErsatzTV.Application/Search/Queries/QuerySearchIndexAllItemsHandler.cs
  16. 8
      ErsatzTV.Application/Search/Queries/QuerySearchIndexOtherVideos.cs
  17. 44
      ErsatzTV.Application/Search/Queries/QuerySearchIndexOtherVideosHandler.cs
  18. 3
      ErsatzTV.Application/Search/SearchResultAllItemsViewModel.cs
  19. 3
      ErsatzTV.Core/Domain/Library/LibraryMediaKind.cs
  20. 10
      ErsatzTV.Core/Domain/MediaItem/OtherVideo.cs
  21. 8
      ErsatzTV.Core/Domain/Metadata/OtherVideoMetadata.cs
  22. 1
      ErsatzTV.Core/Interfaces/Metadata/IFallbackMetadataProvider.cs
  23. 1
      ErsatzTV.Core/Interfaces/Metadata/ILocalMetadataProvider.cs
  24. 15
      ErsatzTV.Core/Interfaces/Metadata/IOtherVideoFolderScanner.cs
  25. 19
      ErsatzTV.Core/Interfaces/Repositories/IOtherVideoRepository.cs
  26. 52
      ErsatzTV.Core/Metadata/FallbackMetadataProvider.cs
  27. 1
      ErsatzTV.Core/Metadata/LocalFolderScanner.cs
  28. 47
      ErsatzTV.Core/Metadata/LocalMetadataProvider.cs
  29. 2
      ErsatzTV.Core/Metadata/LocalStatisticsProvider.cs
  30. 197
      ErsatzTV.Core/Metadata/OtherVideoFolderScanner.cs
  31. 34
      ErsatzTV.Core/Scheduling/PlayoutBuilder.cs
  32. 23
      ErsatzTV.Infrastructure/Data/Configurations/MediaItem/OtherVideoConfiguration.cs
  33. 22
      ErsatzTV.Infrastructure/Data/Configurations/Metadata/OtherVideoMetadataConfiguration.cs
  34. 26
      ErsatzTV.Infrastructure/Data/Repositories/MediaCollectionRepository.cs
  35. 143
      ErsatzTV.Infrastructure/Data/Repositories/OtherVideoRepository.cs
  36. 4
      ErsatzTV.Infrastructure/Data/Repositories/SearchRepository.cs
  37. 2
      ErsatzTV.Infrastructure/Data/TvContext.cs
  38. 3352
      ErsatzTV.Infrastructure/Migrations/20211014025559_Add_LocalLibrary_OtherVideos.Designer.cs
  39. 24
      ErsatzTV.Infrastructure/Migrations/20211014025559_Add_LocalLibrary_OtherVideos.cs
  40. 3507
      ErsatzTV.Infrastructure/Migrations/20211014123441_Add_OtherVideo_OtherVideoMetadata.Designer.cs
  41. 287
      ErsatzTV.Infrastructure/Migrations/20211014123441_Add_OtherVideo_OtherVideoMetadata.cs
  42. 157
      ErsatzTV.Infrastructure/Migrations/TvContextModelSnapshot.cs
  43. 46
      ErsatzTV.Infrastructure/Search/SearchIndex.cs
  44. 42
      ErsatzTV/Pages/CollectionItems.razor
  45. 9
      ErsatzTV/Pages/MultiSelectBase.cs
  46. 158
      ErsatzTV/Pages/OtherVideoList.razor
  47. 69
      ErsatzTV/Pages/Search.razor
  48. 3
      ErsatzTV/Shared/MainLayout.razor
  49. 2
      ErsatzTV/Startup.cs

6
CHANGELOG.md

@ -5,9 +5,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). @@ -5,9 +5,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
## [Unreleased]
### Fixed
- Fix error message/offline continuity with channels that use HLS Segmenter
- Fix error message/offline stream continuity with channels that use HLS Segmenter
- Fix removing items from search index when folders are removed from local libraries
### Added
- Add `Other Video` local libraries
- Other video items require no metadata or particular folder layout, and will have tags added for each containing folder
- For Example, a video at `commercials/sd/1990/whatever.mkv` will have the tags `commercials`, `sd` and `1990`, and the title `whatever`
- Add filler `Tail Mode` option to `Duration` playout mode (in addition to existing `Offline` option)
- Filler collection will always be randomized (to fill as much time as possible)
- Filler will be hidden from channel guide, but visible in playout details in ErsatzTV

18
ErsatzTV.Application/Libraries/Commands/UpdateLocalLibraryHandler.cs

@ -1,4 +1,5 @@ @@ -1,4 +1,5 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading;
@ -8,6 +9,7 @@ using ErsatzTV.Application.MediaSources.Commands; @@ -8,6 +9,7 @@ using ErsatzTV.Application.MediaSources.Commands;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Locking;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using LanguageExt;
@ -22,15 +24,18 @@ namespace ErsatzTV.Application.Libraries.Commands @@ -22,15 +24,18 @@ namespace ErsatzTV.Application.Libraries.Commands
{
private readonly ChannelWriter<IBackgroundServiceRequest> _workerChannel;
private readonly IEntityLocker _entityLocker;
private readonly ISearchIndex _searchIndex;
private readonly IDbContextFactory<TvContext> _dbContextFactory;
public UpdateLocalLibraryHandler(
ChannelWriter<IBackgroundServiceRequest> workerChannel,
IEntityLocker entityLocker,
ISearchIndex searchIndex,
IDbContextFactory<TvContext> dbContextFactory)
{
_workerChannel = workerChannel;
_entityLocker = entityLocker;
_searchIndex = searchIndex;
_dbContextFactory = dbContextFactory;
}
@ -56,10 +61,21 @@ namespace ErsatzTV.Application.Libraries.Commands @@ -56,10 +61,21 @@ namespace ErsatzTV.Application.Libraries.Commands
.Filter(ep => incoming.Paths.All(p => NormalizePath(p.Path) != NormalizePath(ep.Path)))
.ToList();
var toRemoveIds = toRemove.Map(lp => lp.Id).ToList();
List<int> itemsToRemove = await dbContext.MediaItems
.Filter(mi => toRemoveIds.Contains(mi.LibraryPathId))
.Map(mi => mi.Id)
.ToListAsync();
existing.Paths.RemoveAll(toRemove.Contains);
existing.Paths.AddRange(toAdd);
await dbContext.SaveChangesAsync();
if (await dbContext.SaveChangesAsync() > 0)
{
await _searchIndex.RemoveItems(itemsToRemove);
_searchIndex.Commit();
}
if (toAdd.Count > 0 || toRemove.Count > 0 && _entityLocker.LockLibrary(existing.Id))
{

3
ErsatzTV.Application/MediaCards/CollectionCardResultsViewModel.cs

@ -9,7 +9,8 @@ namespace ErsatzTV.Application.MediaCards @@ -9,7 +9,8 @@ namespace ErsatzTV.Application.MediaCards
List<TelevisionSeasonCardViewModel> SeasonCards,
List<TelevisionEpisodeCardViewModel> EpisodeCards,
List<ArtistCardViewModel> ArtistCards,
List<MusicVideoCardViewModel> MusicVideoCards)
List<MusicVideoCardViewModel> MusicVideoCards,
List<OtherVideoCardViewModel> OtherVideoCards)
{
public bool UseCustomPlaybackOrder { get; set; }
}

9
ErsatzTV.Application/MediaCards/Mapper.cs

@ -103,6 +103,13 @@ namespace ErsatzTV.Application.MediaCards @@ -103,6 +103,13 @@ namespace ErsatzTV.Application.MediaCards
musicVideoMetadata.Album,
GetThumbnail(musicVideoMetadata, None, None));
internal static OtherVideoCardViewModel ProjectToViewModel(OtherVideoMetadata otherVideoMetadata) =>
new(
otherVideoMetadata.OtherVideoId,
otherVideoMetadata.Title,
otherVideoMetadata.OriginalTitle,
otherVideoMetadata.SortTitle);
internal static ArtistCardViewModel ProjectToViewModel(ArtistMetadata artistMetadata) =>
new(
artistMetadata.ArtistId,
@ -133,6 +140,8 @@ namespace ErsatzTV.Application.MediaCards @@ -133,6 +140,8 @@ namespace ErsatzTV.Application.MediaCards
.ToList(),
collection.MediaItems.OfType<Artist>().Map(a => ProjectToViewModel(a.ArtistMetadata.Head())).ToList(),
collection.MediaItems.OfType<MusicVideo>().Map(mv => ProjectToViewModel(mv.MusicVideoMetadata.Head()))
.ToList(),
collection.MediaItems.OfType<OtherVideo>().Map(mv => ProjectToViewModel(mv.OtherVideoMetadata.Head()))
.ToList()) { UseCustomPlaybackOrder = collection.UseCustomPlaybackOrder };
internal static ActorCardViewModel ProjectToViewModel(

11
ErsatzTV.Application/MediaCards/OtherVideoCardResultsViewModel.cs

@ -0,0 +1,11 @@ @@ -0,0 +1,11 @@
using System.Collections.Generic;
using ErsatzTV.Core.Search;
using LanguageExt;
namespace ErsatzTV.Application.MediaCards
{
public record OtherVideoCardResultsViewModel(
int Count,
List<OtherVideoCardViewModel> Cards,
Option<SearchPageMap> PageMap);
}

17
ErsatzTV.Application/MediaCards/OtherVideoCardViewModel.cs

@ -0,0 +1,17 @@ @@ -0,0 +1,17 @@
namespace ErsatzTV.Application.MediaCards
{
public record OtherVideoCardViewModel
(
int OtherVideoId,
string Title,
string Subtitle,
string SortTitle) : MediaCardViewModel(
OtherVideoId,
Title,
Subtitle,
SortTitle,
null)
{
public int CustomIndex { get; set; }
}
}

3
ErsatzTV.Application/MediaCards/Queries/GetCollectionCardsHandler.cs

@ -80,6 +80,9 @@ namespace ErsatzTV.Application.MediaCards.Queries @@ -80,6 +80,9 @@ namespace ErsatzTV.Application.MediaCards.Queries
.Include(c => c.MediaItems)
.ThenInclude(i => (i as Episode).Season)
.ThenInclude(s => s.SeasonMetadata)
.Include(c => c.MediaItems)
.ThenInclude(i => (i as OtherVideo).OtherVideoMetadata)
.ThenInclude(ovm => ovm.Artwork)
.SelectOneAsync(c => c.Id, c => c.Id == request.Id)
.Map(c => c.ToEither(BaseError.New("Unable to load collection")))
.MapT(c => ProjectToViewModel(c, maybeJellyfin, maybeEmby));

3
ErsatzTV.Application/MediaCollections/Commands/AddItemsToCollection.cs

@ -12,5 +12,6 @@ namespace ErsatzTV.Application.MediaCollections.Commands @@ -12,5 +12,6 @@ namespace ErsatzTV.Application.MediaCollections.Commands
List<int> SeasonIds,
List<int> EpisodeIds,
List<int> ArtistIds,
List<int> MusicVideoIds) : MediatR.IRequest<Either<BaseError, Unit>>;
List<int> MusicVideoIds,
List<int> OtherVideoIds) : MediatR.IRequest<Either<BaseError, Unit>>;
}

1
ErsatzTV.Application/MediaCollections/Commands/AddItemsToCollectionHandler.cs

@ -56,6 +56,7 @@ namespace ErsatzTV.Application.MediaCollections.Commands @@ -56,6 +56,7 @@ namespace ErsatzTV.Application.MediaCollections.Commands
.Append(request.EpisodeIds)
.Append(request.ArtistIds)
.Append(request.MusicVideoIds)
.Append(request.OtherVideoIds)
.ToList();
var toAddIds = allItems.Where(item => collection.MediaItems.All(mi => mi.Id != item)).ToList();

8
ErsatzTV.Application/MediaCollections/Commands/AddOtherVideoToCollection.cs

@ -0,0 +1,8 @@ @@ -0,0 +1,8 @@
using ErsatzTV.Core;
using LanguageExt;
namespace ErsatzTV.Application.MediaCollections.Commands
{
public record AddOtherVideoToCollection
(int CollectionId, int OtherVideoId) : MediatR.IRequest<Either<BaseError, Unit>>;
}

80
ErsatzTV.Application/MediaCollections/Commands/AddOtherVideoToCollectionHandler.cs

@ -0,0 +1,80 @@ @@ -0,0 +1,80 @@
using System.Threading;
using System.Threading.Channels;
using System.Threading.Tasks;
using ErsatzTV.Application.Playouts.Commands;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using LanguageExt;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.MediaCollections.Commands
{
public class AddOtherVideoToCollectionHandler :
MediatR.IRequestHandler<AddOtherVideoToCollection, Either<BaseError, Unit>>
{
private readonly ChannelWriter<IBackgroundServiceRequest> _channel;
private readonly IDbContextFactory<TvContext> _dbContextFactory;
private readonly IMediaCollectionRepository _mediaCollectionRepository;
public AddOtherVideoToCollectionHandler(
IDbContextFactory<TvContext> dbContextFactory,
IMediaCollectionRepository mediaCollectionRepository,
ChannelWriter<IBackgroundServiceRequest> channel)
{
_dbContextFactory = dbContextFactory;
_mediaCollectionRepository = mediaCollectionRepository;
_channel = channel;
}
public async Task<Either<BaseError, Unit>> Handle(
AddOtherVideoToCollection request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
Validation<BaseError, Parameters> validation = await Validate(dbContext, request);
return await validation.Apply(parameters => ApplyAddOtherVideoRequest(dbContext, parameters));
}
private async Task<Unit> ApplyAddOtherVideoRequest(TvContext dbContext, Parameters parameters)
{
parameters.Collection.MediaItems.Add(parameters.OtherVideo);
if (await dbContext.SaveChangesAsync() > 0)
{
// rebuild all playouts that use this collection
foreach (int playoutId in await _mediaCollectionRepository
.PlayoutIdsUsingCollection(parameters.Collection.Id))
{
await _channel.WriteAsync(new BuildPlayout(playoutId, true));
}
}
return Unit.Default;
}
private static async Task<Validation<BaseError, Parameters>> Validate(
TvContext dbContext,
AddOtherVideoToCollection request) =>
(await CollectionMustExist(dbContext, request), await ValidateOtherVideo(dbContext, request))
.Apply((collection, episode) => new Parameters(collection, episode));
private static Task<Validation<BaseError, Collection>> CollectionMustExist(
TvContext dbContext,
AddOtherVideoToCollection request) =>
dbContext.Collections
.Include(c => c.MediaItems)
.SelectOneAsync(c => c.Id, c => c.Id == request.CollectionId)
.Map(o => o.ToValidation<BaseError>("Collection does not exist."));
private static Task<Validation<BaseError, OtherVideo>> ValidateOtherVideo(
TvContext dbContext,
AddOtherVideoToCollection request) =>
dbContext.OtherVideos
.SelectOneAsync(m => m.Id, e => e.Id == request.OtherVideoId)
.Map(o => o.ToValidation<BaseError>("OtherVideo does not exist"));
private record Parameters(Collection Collection, OtherVideo OtherVideo);
}
}

10
ErsatzTV.Application/MediaSources/Commands/ScanLocalLibraryHandler.cs

@ -26,6 +26,7 @@ namespace ErsatzTV.Application.MediaSources.Commands @@ -26,6 +26,7 @@ namespace ErsatzTV.Application.MediaSources.Commands
private readonly IMediator _mediator;
private readonly IMovieFolderScanner _movieFolderScanner;
private readonly IMusicVideoFolderScanner _musicVideoFolderScanner;
private readonly IOtherVideoFolderScanner _otherVideoFolderScanner;
private readonly ITelevisionFolderScanner _televisionFolderScanner;
public ScanLocalLibraryHandler(
@ -34,6 +35,7 @@ namespace ErsatzTV.Application.MediaSources.Commands @@ -34,6 +35,7 @@ namespace ErsatzTV.Application.MediaSources.Commands
IMovieFolderScanner movieFolderScanner,
ITelevisionFolderScanner televisionFolderScanner,
IMusicVideoFolderScanner musicVideoFolderScanner,
IOtherVideoFolderScanner otherVideoFolderScanner,
IEntityLocker entityLocker,
IMediator mediator,
ILogger<ScanLocalLibraryHandler> logger)
@ -43,6 +45,7 @@ namespace ErsatzTV.Application.MediaSources.Commands @@ -43,6 +45,7 @@ namespace ErsatzTV.Application.MediaSources.Commands
_movieFolderScanner = movieFolderScanner;
_televisionFolderScanner = televisionFolderScanner;
_musicVideoFolderScanner = musicVideoFolderScanner;
_otherVideoFolderScanner = otherVideoFolderScanner;
_entityLocker = entityLocker;
_mediator = mediator;
_logger = logger;
@ -107,6 +110,13 @@ namespace ErsatzTV.Application.MediaSources.Commands @@ -107,6 +110,13 @@ namespace ErsatzTV.Application.MediaSources.Commands
progressMin,
progressMax);
break;
case LibraryMediaKind.OtherVideos:
await _otherVideoFolderScanner.ScanFolder(
libraryPath,
ffprobePath,
progressMin,
progressMax);
break;
}
libraryPath.LastScan = DateTime.UtcNow;

4
ErsatzTV.Application/Playouts/Mapper.cs

@ -37,6 +37,9 @@ namespace ErsatzTV.Application.Playouts @@ -37,6 +37,9 @@ namespace ErsatzTV.Application.Playouts
return mv.MusicVideoMetadata.HeadOrNone()
.Map(mvm => $"{artistName}{mvm.Title}")
.IfNone("[unknown music video]");
case OtherVideo ov:
return ov.OtherVideoMetadata.HeadOrNone().Map(ovm => ovm.Title ?? string.Empty)
.IfNone("[unknown video]");
default:
return string.Empty;
}
@ -49,6 +52,7 @@ namespace ErsatzTV.Application.Playouts @@ -49,6 +52,7 @@ namespace ErsatzTV.Application.Playouts
Movie m => m.MediaVersions.Head(),
Episode e => e.MediaVersions.Head(),
MusicVideo mv => mv.MediaVersions.Head(),
OtherVideo ov => ov.MediaVersions.Head(),
_ => throw new ArgumentOutOfRangeException(nameof(mediaItem))
};

4
ErsatzTV.Application/Playouts/Queries/GetFuturePlayoutItemsByIdHandler.cs

@ -52,6 +52,10 @@ namespace ErsatzTV.Application.Playouts.Queries @@ -52,6 +52,10 @@ namespace ErsatzTV.Application.Playouts.Queries
.Include(i => i.MediaItem)
.ThenInclude(mi => (mi as Episode).Season.Show)
.ThenInclude(s => s.ShowMetadata)
.Include(i => i.MediaItem)
.ThenInclude(mi => (mi as OtherVideo).OtherVideoMetadata)
.Include(i => i.MediaItem)
.ThenInclude(mi => (mi as OtherVideo).MediaVersions)
.Filter(i => i.PlayoutId == request.PlayoutId)
.Filter(i => i.Finish >= now)
.OrderBy(i => i.Start)

14
ErsatzTV.Application/Search/Queries/QuerySearchIndexAllItemsHandler.cs

@ -3,6 +3,7 @@ using System.Linq; @@ -3,6 +3,7 @@ using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Infrastructure.Search;
using LanguageExt;
using MediatR;
@ -19,12 +20,13 @@ namespace ErsatzTV.Application.Search.Queries @@ -19,12 +20,13 @@ namespace ErsatzTV.Application.Search.Queries
QuerySearchIndexAllItems request,
CancellationToken cancellationToken) =>
new(
await GetIds("movie", request.Query),
await GetIds("show", request.Query),
await GetIds("season", request.Query),
await GetIds("episode", request.Query),
await GetIds("artist", request.Query),
await GetIds("music_video", request.Query));
await GetIds(SearchIndex.MovieType, request.Query),
await GetIds(SearchIndex.ShowType, request.Query),
await GetIds(SearchIndex.SeasonType, request.Query),
await GetIds(SearchIndex.EpisodeType, request.Query),
await GetIds(SearchIndex.ArtistType, request.Query),
await GetIds(SearchIndex.MusicVideoType, request.Query),
await GetIds(SearchIndex.OtherVideoType, request.Query));
private Task<List<int>> GetIds(string type, string query) =>
_searchIndex.Search($"type:{type} AND ({query})", 0, 0)

8
ErsatzTV.Application/Search/Queries/QuerySearchIndexOtherVideos.cs

@ -0,0 +1,8 @@ @@ -0,0 +1,8 @@
using ErsatzTV.Application.MediaCards;
using MediatR;
namespace ErsatzTV.Application.Search.Queries
{
public record QuerySearchIndexOtherVideos
(string Query, int PageNumber, int PageSize) : IRequest<OtherVideoCardResultsViewModel>;
}

44
ErsatzTV.Application/Search/Queries/QuerySearchIndexOtherVideosHandler.cs

@ -0,0 +1,44 @@ @@ -0,0 +1,44 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Application.MediaCards;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Core.Search;
using LanguageExt;
using MediatR;
using static ErsatzTV.Application.MediaCards.Mapper;
namespace ErsatzTV.Application.Search.Queries
{
public class
QuerySearchIndexOtherVideosHandler : IRequestHandler<QuerySearchIndexOtherVideos,
OtherVideoCardResultsViewModel>
{
private readonly IOtherVideoRepository _otherVideoRepository;
private readonly ISearchIndex _searchIndex;
public QuerySearchIndexOtherVideosHandler(ISearchIndex searchIndex, IOtherVideoRepository otherVideoRepository)
{
_searchIndex = searchIndex;
_otherVideoRepository = otherVideoRepository;
}
public async Task<OtherVideoCardResultsViewModel> Handle(
QuerySearchIndexOtherVideos request,
CancellationToken cancellationToken)
{
SearchResult searchResult = await _searchIndex.Search(
request.Query,
(request.PageNumber - 1) * request.PageSize,
request.PageSize);
List<OtherVideoCardViewModel> items = await _otherVideoRepository
.GetOtherVideosForCards(searchResult.Items.Map(i => i.Id).ToList())
.Map(list => list.Map(ProjectToViewModel).ToList());
return new OtherVideoCardResultsViewModel(searchResult.TotalCount, items, searchResult.PageMap);
}
}
}

3
ErsatzTV.Application/Search/SearchResultAllItemsViewModel.cs

@ -8,5 +8,6 @@ namespace ErsatzTV.Application.Search @@ -8,5 +8,6 @@ namespace ErsatzTV.Application.Search
List<int> SeasonIds,
List<int> EpisodeIds,
List<int> ArtistIds,
List<int> MusicVideoIds);
List<int> MusicVideoIds,
List<int> OtherVideoIds);
}

3
ErsatzTV.Core/Domain/Library/LibraryMediaKind.cs

@ -4,6 +4,7 @@ @@ -4,6 +4,7 @@
{
Movies = 1,
Shows = 2,
MusicVideos = 3
MusicVideos = 3,
OtherVideos = 4
}
}

10
ErsatzTV.Core/Domain/MediaItem/OtherVideo.cs

@ -0,0 +1,10 @@ @@ -0,0 +1,10 @@
using System.Collections.Generic;
namespace ErsatzTV.Core.Domain
{
public class OtherVideo : MediaItem
{
public List<OtherVideoMetadata> OtherVideoMetadata { get; set; }
public List<MediaVersion> MediaVersions { get; set; }
}
}

8
ErsatzTV.Core/Domain/Metadata/OtherVideoMetadata.cs

@ -0,0 +1,8 @@ @@ -0,0 +1,8 @@
namespace ErsatzTV.Core.Domain
{
public class OtherVideoMetadata : Metadata
{
public int OtherVideoId { get; set; }
public OtherVideo OtherVideo { get; set; }
}
}

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

@ -11,6 +11,7 @@ namespace ErsatzTV.Core.Interfaces.Metadata @@ -11,6 +11,7 @@ namespace ErsatzTV.Core.Interfaces.Metadata
List<EpisodeMetadata> GetFallbackMetadata(Episode episode);
MovieMetadata GetFallbackMetadata(Movie movie);
Option<MusicVideoMetadata> GetFallbackMetadata(MusicVideo musicVideo);
Option<OtherVideoMetadata> GetFallbackMetadata(OtherVideo otherVideo);
string GetSortTitle(string title);
}
}

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

@ -16,6 +16,7 @@ namespace ErsatzTV.Core.Interfaces.Metadata @@ -16,6 +16,7 @@ namespace ErsatzTV.Core.Interfaces.Metadata
Task<bool> RefreshFallbackMetadata(Episode episode);
Task<bool> RefreshFallbackMetadata(Artist artist, string artistFolder);
Task<bool> RefreshFallbackMetadata(MusicVideo musicVideo);
Task<bool> RefreshFallbackMetadata(OtherVideo otherVideo);
Task<bool> RefreshFallbackMetadata(Show televisionShow, string showFolder);
}
}

15
ErsatzTV.Core/Interfaces/Metadata/IOtherVideoFolderScanner.cs

@ -0,0 +1,15 @@ @@ -0,0 +1,15 @@
using System.Threading.Tasks;
using ErsatzTV.Core.Domain;
using LanguageExt;
namespace ErsatzTV.Core.Interfaces.Metadata
{
public interface IOtherVideoFolderScanner
{
Task<Either<BaseError, Unit>> ScanFolder(
LibraryPath libraryPath,
string ffprobePath,
decimal progressMin,
decimal progressMax);
}
}

19
ErsatzTV.Core/Interfaces/Repositories/IOtherVideoRepository.cs

@ -0,0 +1,19 @@ @@ -0,0 +1,19 @@
using System.Collections.Generic;
using System.Threading.Tasks;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Metadata;
using LanguageExt;
namespace ErsatzTV.Core.Interfaces.Repositories
{
public interface IOtherVideoRepository
{
Task<Either<BaseError, MediaItemScanResult<OtherVideo>>> GetOrAdd(LibraryPath libraryPath, string path);
Task<IEnumerable<string>> FindOtherVideoPaths(LibraryPath libraryPath);
Task<List<int>> DeleteByPath(LibraryPath libraryPath, string path);
Task<bool> AddTag(OtherVideoMetadata metadata, Tag tag);
Task<List<OtherVideoMetadata>> GetOtherVideosForCards(List<int> ids);
// Task<int> GetOtherVideoCount(int artistId);
// Task<List<OtherVideoMetadata>> GetPagedOtherVideos(int artistId, int pageNumber, int pageSize);
}
}

52
ErsatzTV.Core/Metadata/FallbackMetadataProvider.cs

@ -1,6 +1,7 @@ @@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Metadata;
@ -88,6 +89,20 @@ namespace ErsatzTV.Core.Metadata @@ -88,6 +89,20 @@ namespace ErsatzTV.Core.Metadata
return GetMusicVideoMetadata(fileName, metadata);
}
public Option<OtherVideoMetadata> GetFallbackMetadata(OtherVideo otherVideo)
{
string path = otherVideo.MediaVersions.Head().MediaFiles.Head().Path;
string fileName = Path.GetFileNameWithoutExtension(path);
var metadata = new OtherVideoMetadata
{
MetadataKind = MetadataKind.Fallback,
Title = fileName ?? path,
OtherVideo = otherVideo
};
return GetOtherVideoMetadata(path, metadata);
}
public string GetSortTitle(string title)
{
@ -214,6 +229,43 @@ namespace ErsatzTV.Core.Metadata @@ -214,6 +229,43 @@ namespace ErsatzTV.Core.Metadata
return None;
}
}
private Option<OtherVideoMetadata> GetOtherVideoMetadata(string path, OtherVideoMetadata metadata)
{
try
{
string folder = Path.GetDirectoryName(path);
if (folder == null)
{
return None;
}
string libraryPath = metadata.OtherVideo.LibraryPath.Path;
string parent = Optional(Directory.GetParent(libraryPath)).Match(
di => di.FullName,
() => libraryPath);
string diff = Path.GetRelativePath(parent, folder);
var tags = diff.Split(Path.DirectorySeparatorChar)
.Map(t => new Tag { Name = t })
.ToList();
metadata.Artwork = new List<Artwork>();
metadata.Actors = new List<Actor>();
metadata.Genres = new List<Genre>();
metadata.Tags = tags;
metadata.Studios = new List<Studio>();
metadata.DateUpdated = DateTime.UtcNow;
metadata.OriginalTitle = Path.GetRelativePath(libraryPath, path);
return metadata;
}
catch (Exception)
{
return None;
}
}
private ShowMetadata GetTelevisionShowMetadata(string fileName, ShowMetadata metadata)
{

1
ErsatzTV.Core/Metadata/LocalFolderScanner.cs

@ -73,6 +73,7 @@ namespace ErsatzTV.Core.Metadata @@ -73,6 +73,7 @@ namespace ErsatzTV.Core.Metadata
Movie m => m.MediaVersions.Head(),
Episode e => e.MediaVersions.Head(),
MusicVideo mv => mv.MediaVersions.Head(),
OtherVideo ov => ov.MediaVersions.Head(),
_ => throw new ArgumentOutOfRangeException(nameof(mediaItem))
};

47
ErsatzTV.Core/Metadata/LocalMetadataProvider.cs

@ -30,6 +30,7 @@ namespace ErsatzTV.Core.Metadata @@ -30,6 +30,7 @@ namespace ErsatzTV.Core.Metadata
private readonly IMetadataRepository _metadataRepository;
private readonly IMovieRepository _movieRepository;
private readonly IMusicVideoRepository _musicVideoRepository;
private readonly IOtherVideoRepository _otherVideoRepository;
private readonly ITelevisionRepository _televisionRepository;
public LocalMetadataProvider(
@ -38,6 +39,7 @@ namespace ErsatzTV.Core.Metadata @@ -38,6 +39,7 @@ namespace ErsatzTV.Core.Metadata
ITelevisionRepository televisionRepository,
IArtistRepository artistRepository,
IMusicVideoRepository musicVideoRepository,
IOtherVideoRepository otherVideoRepository,
IFallbackMetadataProvider fallbackMetadataProvider,
ILocalFileSystem localFileSystem,
IEpisodeNfoReader episodeNfoReader,
@ -48,6 +50,7 @@ namespace ErsatzTV.Core.Metadata @@ -48,6 +50,7 @@ namespace ErsatzTV.Core.Metadata
_televisionRepository = televisionRepository;
_artistRepository = artistRepository;
_musicVideoRepository = musicVideoRepository;
_otherVideoRepository = otherVideoRepository;
_fallbackMetadataProvider = fallbackMetadataProvider;
_localFileSystem = localFileSystem;
_episodeNfoReader = episodeNfoReader;
@ -136,6 +139,11 @@ namespace ErsatzTV.Core.Metadata @@ -136,6 +139,11 @@ namespace ErsatzTV.Core.Metadata
public Task<bool> RefreshFallbackMetadata(Artist artist, string artistFolder) =>
ApplyMetadataUpdate(artist, _fallbackMetadataProvider.GetFallbackMetadataForArtist(artistFolder));
public Task<bool> RefreshFallbackMetadata(OtherVideo otherVideo) =>
_fallbackMetadataProvider.GetFallbackMetadata(otherVideo).Match(
metadata => ApplyMetadataUpdate(otherVideo, metadata),
() => Task.FromResult(false));
public Task<bool> RefreshFallbackMetadata(MusicVideo musicVideo) =>
_fallbackMetadataProvider.GetFallbackMetadata(musicVideo).Match(
metadata => ApplyMetadataUpdate(musicVideo, metadata),
@ -614,6 +622,45 @@ namespace ErsatzTV.Core.Metadata @@ -614,6 +622,45 @@ namespace ErsatzTV.Core.Metadata
return await _metadataRepository.Add(metadata);
});
private Task<bool> ApplyMetadataUpdate(OtherVideo otherVideo, OtherVideoMetadata metadata) =>
Optional(otherVideo.OtherVideoMetadata).Flatten().HeadOrNone().Match(
async existing =>
{
existing.Title = metadata.Title;
if (existing.DateAdded == SystemTime.MinValueUtc)
{
existing.DateAdded = metadata.DateAdded;
}
existing.DateUpdated = metadata.DateUpdated;
existing.MetadataKind = metadata.MetadataKind;
existing.SortTitle = string.IsNullOrWhiteSpace(metadata.SortTitle)
? _fallbackMetadataProvider.GetSortTitle(metadata.Title)
: metadata.SortTitle;
existing.OriginalTitle = metadata.OriginalTitle;
bool updated = await UpdateMetadataCollections(
existing,
metadata,
(_, _) => Task.FromResult(false),
_otherVideoRepository.AddTag,
(_, _) => Task.FromResult(false),
(_, _) => Task.FromResult(false));
return await _metadataRepository.Update(existing) || updated;
},
async () =>
{
metadata.SortTitle = string.IsNullOrWhiteSpace(metadata.SortTitle)
? _fallbackMetadataProvider.GetSortTitle(metadata.Title)
: metadata.SortTitle;
metadata.OtherVideoId = otherVideo.Id;
otherVideo.OtherVideoMetadata = new List<OtherVideoMetadata> { metadata };
return await _metadataRepository.Add(metadata);
});
private async Task<Option<ShowMetadata>> LoadTelevisionShowMetadata(string nfoFileName)
{

2
ErsatzTV.Core/Metadata/LocalStatisticsProvider.cs

@ -39,6 +39,7 @@ namespace ErsatzTV.Core.Metadata @@ -39,6 +39,7 @@ namespace ErsatzTV.Core.Metadata
Movie m => m.MediaVersions.Head().MediaFiles.Head().Path,
Episode e => e.MediaVersions.Head().MediaFiles.Head().Path,
MusicVideo mv => mv.MediaVersions.Head().MediaFiles.Head().Path,
OtherVideo ov => ov.MediaVersions.Head().MediaFiles.Head().Path,
_ => throw new ArgumentOutOfRangeException(nameof(mediaItem))
};
@ -82,6 +83,7 @@ namespace ErsatzTV.Core.Metadata @@ -82,6 +83,7 @@ namespace ErsatzTV.Core.Metadata
Movie m => m.MediaVersions.Head(),
Episode e => e.MediaVersions.Head(),
MusicVideo mv => mv.MediaVersions.Head(),
OtherVideo ov => ov.MediaVersions.Head(),
_ => throw new ArgumentOutOfRangeException(nameof(mediaItem))
};

197
ErsatzTV.Core/Metadata/OtherVideoFolderScanner.cs

@ -0,0 +1,197 @@ @@ -0,0 +1,197 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Interfaces.Images;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Search;
using LanguageExt;
using MediatR;
using Microsoft.Extensions.Logging;
using static LanguageExt.Prelude;
using Unit = LanguageExt.Unit;
namespace ErsatzTV.Core.Metadata
{
public class OtherVideoFolderScanner : LocalFolderScanner, IOtherVideoFolderScanner
{
private readonly ILocalFileSystem _localFileSystem;
private readonly ILocalMetadataProvider _localMetadataProvider;
private readonly IMediator _mediator;
private readonly ISearchIndex _searchIndex;
private readonly ISearchRepository _searchRepository;
private readonly IOtherVideoRepository _otherVideoRepository;
private readonly ILibraryRepository _libraryRepository;
private readonly ILogger<OtherVideoFolderScanner> _logger;
public OtherVideoFolderScanner(
ILocalFileSystem localFileSystem,
ILocalStatisticsProvider localStatisticsProvider,
ILocalMetadataProvider localMetadataProvider,
IMetadataRepository metadataRepository,
IImageCache imageCache,
IMediator mediator,
ISearchIndex searchIndex,
ISearchRepository searchRepository,
IOtherVideoRepository otherVideoRepository,
ILibraryRepository libraryRepository,
ILogger<OtherVideoFolderScanner> logger) : base(
localFileSystem,
localStatisticsProvider,
metadataRepository,
imageCache,
logger)
{
_localFileSystem = localFileSystem;
_localMetadataProvider = localMetadataProvider;
_mediator = mediator;
_searchIndex = searchIndex;
_searchRepository = searchRepository;
_otherVideoRepository = otherVideoRepository;
_libraryRepository = libraryRepository;
_logger = logger;
}
public async Task<Either<BaseError, Unit>> ScanFolder(
LibraryPath libraryPath,
string ffprobePath,
decimal progressMin,
decimal progressMax)
{
decimal progressSpread = progressMax - progressMin;
if (!_localFileSystem.IsLibraryPathAccessible(libraryPath))
{
return new MediaSourceInaccessible();
}
var foldersCompleted = 0;
var folderQueue = new Queue<string>();
foreach (string folder in _localFileSystem.ListSubdirectories(libraryPath.Path)
.Filter(ShouldIncludeFolder)
.OrderBy(identity))
{
folderQueue.Enqueue(folder);
}
while (folderQueue.Count > 0)
{
decimal percentCompletion = (decimal)foldersCompleted / (foldersCompleted + folderQueue.Count);
await _mediator.Publish(
new LibraryScanProgress(libraryPath.LibraryId, progressMin + percentCompletion * progressSpread));
string otherVideoFolder = folderQueue.Dequeue();
foldersCompleted++;
var filesForEtag = _localFileSystem.ListFiles(otherVideoFolder).ToList();
var allFiles = filesForEtag
.Filter(f => VideoFileExtensions.Contains(Path.GetExtension(f)))
.Filter(f => !Path.GetFileName(f).StartsWith("._"))
.ToList();
foreach (string subdirectory in _localFileSystem.ListSubdirectories(otherVideoFolder)
.Filter(ShouldIncludeFolder)
.OrderBy(identity))
{
folderQueue.Enqueue(subdirectory);
}
string etag = FolderEtag.Calculate(otherVideoFolder, _localFileSystem);
Option<LibraryFolder> knownFolder = libraryPath.LibraryFolders
.Filter(f => f.Path == otherVideoFolder)
.HeadOrNone();
// skip folder if etag matches
if (!allFiles.Any() || await knownFolder.Map(f => f.Etag ?? string.Empty).IfNoneAsync(string.Empty) == etag)
{
continue;
}
_logger.LogDebug(
"UPDATE: Etag has changed for folder {Folder}",
otherVideoFolder);
foreach (string file in allFiles.OrderBy(identity))
{
_logger.LogDebug("Other video found at {File}", file);
Either<BaseError, MediaItemScanResult<OtherVideo>> maybeVideo = await _otherVideoRepository
.GetOrAdd(libraryPath, file)
.BindT(video => UpdateStatistics(video, ffprobePath))
.BindT(UpdateMetadata);
await maybeVideo.Match(
async result =>
{
if (result.IsAdded)
{
await _searchIndex.AddItems(_searchRepository, new List<MediaItem> { result.Item });
}
else if (result.IsUpdated)
{
await _searchIndex.UpdateItems(_searchRepository, new List<MediaItem> { result.Item });
}
await _libraryRepository.SetEtag(libraryPath, knownFolder, otherVideoFolder, etag);
},
error =>
{
_logger.LogWarning("Error processing other video at {Path}: {Error}", file, error.Value);
return Task.CompletedTask;
});
}
}
foreach (string path in await _otherVideoRepository.FindOtherVideoPaths(libraryPath))
{
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);
}
else if (Path.GetFileName(path).StartsWith("._"))
{
_logger.LogInformation("Removing dot underscore file at {Path}", path);
List<int> otherVideoIds = await _otherVideoRepository.DeleteByPath(libraryPath, path);
await _searchIndex.RemoveItems(otherVideoIds);
}
}
_searchIndex.Commit();
return Unit.Default;
}
private async Task<Either<BaseError, MediaItemScanResult<OtherVideo>>> UpdateMetadata(
MediaItemScanResult<OtherVideo> result)
{
try
{
OtherVideo otherVideo = result.Item;
if (!Optional(otherVideo.OtherVideoMetadata).Flatten().Any())
{
otherVideo.OtherVideoMetadata ??= new List<OtherVideoMetadata>();
string path = otherVideo.MediaVersions.Head().MediaFiles.Head().Path;
_logger.LogDebug("Refreshing {Attribute} for {Path}", "Fallback Metadata", path);
if (await _localMetadataProvider.RefreshFallbackMetadata(otherVideo))
{
result.IsUpdated = true;
}
}
return result;
}
catch (Exception ex)
{
return BaseError.New(ex.ToString());
}
}
}
}

34
ErsatzTV.Core/Scheduling/PlayoutBuilder.cs

@ -118,14 +118,14 @@ namespace ErsatzTV.Core.Scheduling @@ -118,14 +118,14 @@ namespace ErsatzTV.Core.Scheduling
{
bool isZero = item switch
{
Movie m => await m.MediaVersions.Map(v => v.Duration).HeadOrNone().IfNoneAsync(TimeSpan.Zero) ==
TimeSpan.Zero,
Movie m => await m.MediaVersions.Map(v => v.Duration).HeadOrNone()
.IfNoneAsync(TimeSpan.Zero) == TimeSpan.Zero,
Episode e => await e.MediaVersions.Map(v => v.Duration).HeadOrNone()
.IfNoneAsync(TimeSpan.Zero) ==
TimeSpan.Zero,
.IfNoneAsync(TimeSpan.Zero) == TimeSpan.Zero,
MusicVideo mv => await mv.MediaVersions.Map(v => v.Duration).HeadOrNone()
.IfNoneAsync(TimeSpan.Zero) ==
TimeSpan.Zero,
.IfNoneAsync(TimeSpan.Zero) == TimeSpan.Zero,
OtherVideo ov => await ov.MediaVersions.Map(v => v.Duration).HeadOrNone()
.IfNoneAsync(TimeSpan.Zero) == TimeSpan.Zero,
_ => true
};
@ -160,12 +160,14 @@ namespace ErsatzTV.Core.Scheduling @@ -160,12 +160,14 @@ namespace ErsatzTV.Core.Scheduling
c => c.Value.Any(
mi => mi switch
{
Movie m => m.MediaVersions.HeadOrNone().Map(mv => mv.Duration).IfNone(TimeSpan.Zero) ==
TimeSpan.Zero,
Episode e => e.MediaVersions.HeadOrNone().Map(mv => mv.Duration).IfNone(TimeSpan.Zero) ==
TimeSpan.Zero,
MusicVideo mv => mv.MediaVersions.HeadOrNone().Map(v => v.Duration).IfNone(TimeSpan.Zero) ==
TimeSpan.Zero,
Movie m => m.MediaVersions.HeadOrNone().Map(mv => mv.Duration)
.IfNone(TimeSpan.Zero) == TimeSpan.Zero,
Episode e => e.MediaVersions.HeadOrNone().Map(mv => mv.Duration)
.IfNone(TimeSpan.Zero) == TimeSpan.Zero,
MusicVideo mv => mv.MediaVersions.HeadOrNone().Map(v => v.Duration)
.IfNone(TimeSpan.Zero) == TimeSpan.Zero,
OtherVideo ov => ov.MediaVersions.HeadOrNone().Map(v => v.Duration)
.IfNone(TimeSpan.Zero) == TimeSpan.Zero,
_ => true
})).Map(c => c.Key);
if (zeroDurationCollection.IsSome)
@ -273,6 +275,7 @@ namespace ErsatzTV.Core.Scheduling @@ -273,6 +275,7 @@ namespace ErsatzTV.Core.Scheduling
Movie m => m.MediaVersions.Head(),
Episode e => e.MediaVersions.Head(),
MusicVideo mv => mv.MediaVersions.Head(),
OtherVideo mv => mv.MediaVersions.Head(),
_ => throw new ArgumentOutOfRangeException(nameof(mediaItem))
};
@ -344,6 +347,7 @@ namespace ErsatzTV.Core.Scheduling @@ -344,6 +347,7 @@ namespace ErsatzTV.Core.Scheduling
Movie m => m.MediaVersions.Head(),
Episode e => e.MediaVersions.Head(),
MusicVideo mv => mv.MediaVersions.Head(),
OtherVideo ov => ov.MediaVersions.Head(),
_ => throw new ArgumentOutOfRangeException(nameof(peekMediaItem))
};
@ -384,6 +388,7 @@ namespace ErsatzTV.Core.Scheduling @@ -384,6 +388,7 @@ namespace ErsatzTV.Core.Scheduling
Movie m => m.MediaVersions.Head(),
Episode e => e.MediaVersions.Head(),
MusicVideo mv => mv.MediaVersions.Head(),
OtherVideo ov => ov.MediaVersions.Head(),
_ => throw new ArgumentOutOfRangeException(nameof(peekMediaItem))
};
@ -502,6 +507,7 @@ namespace ErsatzTV.Core.Scheduling @@ -502,6 +507,7 @@ namespace ErsatzTV.Core.Scheduling
Movie m => m.MediaVersions.Head(),
Episode e => e.MediaVersions.Head(),
MusicVideo mv => mv.MediaVersions.Head(),
OtherVideo ov => ov.MediaVersions.Head(),
_ => throw new ArgumentOutOfRangeException(nameof(peekMediaItem))
};
@ -734,6 +740,10 @@ namespace ErsatzTV.Core.Scheduling @@ -734,6 +740,10 @@ namespace ErsatzTV.Core.Scheduling
return mv.MusicVideoMetadata.HeadOrNone()
.Map(mvm => $"{artistName}{mvm.Title}")
.IfNone("[unknown music video]");
case OtherVideo ov:
return ov.OtherVideoMetadata.HeadOrNone().Match(
ovm => ovm.Title ?? string.Empty,
() => "[unknown video]");
default:
return string.Empty;
}

23
ErsatzTV.Infrastructure/Data/Configurations/MediaItem/OtherVideoConfiguration.cs

@ -0,0 +1,23 @@ @@ -0,0 +1,23 @@
using ErsatzTV.Core.Domain;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ErsatzTV.Infrastructure.Data.Configurations
{
public class OtherVideoConfiguration : IEntityTypeConfiguration<OtherVideo>
{
public void Configure(EntityTypeBuilder<OtherVideo> builder)
{
builder.ToTable("OtherVideo");
builder.HasMany(m => m.OtherVideoMetadata)
.WithOne(m => m.OtherVideo)
.HasForeignKey(m => m.OtherVideoId)
.OnDelete(DeleteBehavior.Cascade);
builder.HasMany(m => m.MediaVersions)
.WithOne()
.OnDelete(DeleteBehavior.Cascade);
}
}
}

22
ErsatzTV.Infrastructure/Data/Configurations/Metadata/OtherVideoMetadataConfiguration.cs

@ -0,0 +1,22 @@ @@ -0,0 +1,22 @@
using ErsatzTV.Core.Domain;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ErsatzTV.Infrastructure.Data.Configurations
{
public class OtherVideoMetadataConfiguration : IEntityTypeConfiguration<OtherVideoMetadata>
{
public void Configure(EntityTypeBuilder<OtherVideoMetadata> builder)
{
builder.ToTable("OtherVideoMetadata");
builder.HasMany(mm => mm.Artwork)
.WithOne()
.OnDelete(DeleteBehavior.Cascade);
builder.HasMany(mm => mm.Tags)
.WithOne()
.OnDelete(DeleteBehavior.Cascade);
}
}
}

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

@ -54,6 +54,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -54,6 +54,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
result.AddRange(await GetEpisodeItems(dbContext, collectionId));
result.AddRange(await GetArtistItems(dbContext, collectionId));
result.AddRange(await GetMusicVideoItems(dbContext, collectionId));
result.AddRange(await GetOtherVideoItems(dbContext, collectionId));
return result.Distinct().ToList();
}
@ -79,6 +80,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -79,6 +80,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
result.AddRange(await GetEpisodeItems(dbContext, collectionId));
result.AddRange(await GetArtistItems(dbContext, collectionId));
result.AddRange(await GetMusicVideoItems(dbContext, collectionId));
result.AddRange(await GetOtherVideoItems(dbContext, collectionId));
}
foreach (int smartCollectionId in multiCollection.SmartCollections.Map(c => c.Id))
@ -137,6 +139,12 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -137,6 +139,12 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.Map(i => i.Id)
.ToList();
result.AddRange(await GetEpisodeItems(dbContext, episodeIds));
var otherVideoIds = searchResults.Items
.Filter(i => i.Type == SearchIndex.OtherVideoType)
.Map(i => i.Id)
.ToList();
result.AddRange(await GetOtherVideoItems(dbContext, otherVideoIds));
}
return result;
@ -410,6 +418,24 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -410,6 +418,24 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.Include(m => m.MediaVersions)
.Filter(m => musicVideoIds.Contains(m.Id))
.ToListAsync();
private async Task<List<OtherVideo>> GetOtherVideoItems(TvContext dbContext, int collectionId)
{
IEnumerable<int> ids = await _dbConnection.QueryAsync<int>(
@"SELECT o.Id FROM CollectionItem ci
INNER JOIN OtherVideo o ON o.Id = ci.MediaItemId
WHERE ci.CollectionId = @CollectionId",
new { CollectionId = collectionId });
return await GetOtherVideoItems(dbContext, ids);
}
private static Task<List<OtherVideo>> GetOtherVideoItems(TvContext dbContext, IEnumerable<int> otherVideoIds) =>
dbContext.OtherVideos
.Include(m => m.OtherVideoMetadata)
.Include(m => m.MediaVersions)
.Filter(m => otherVideoIds.Contains(m.Id))
.ToListAsync();
private async Task<List<Episode>> GetShowItems(TvContext dbContext, int collectionId)
{

143
ErsatzTV.Infrastructure/Data/Repositories/OtherVideoRepository.cs

@ -0,0 +1,143 @@ @@ -0,0 +1,143 @@
using System;
using System.Collections.Generic;
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 ErsatzTV.Core.Metadata;
using LanguageExt;
using Microsoft.EntityFrameworkCore;
using static LanguageExt.Prelude;
namespace ErsatzTV.Infrastructure.Data.Repositories
{
public class OtherVideoRepository : IOtherVideoRepository
{
private readonly IDbConnection _dbConnection;
private readonly IDbContextFactory<TvContext> _dbContextFactory;
public OtherVideoRepository(IDbConnection dbConnection, IDbContextFactory<TvContext> dbContextFactory)
{
_dbConnection = dbConnection;
_dbContextFactory = dbContextFactory;
}
public async Task<Either<BaseError, MediaItemScanResult<OtherVideo>>> GetOrAdd(
LibraryPath libraryPath,
string path)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
Option<OtherVideo> maybeExisting = await dbContext.OtherVideos
.AsNoTracking()
.Include(ov => ov.OtherVideoMetadata)
.ThenInclude(ovm => ovm.Artwork)
.Include(ov => ov.OtherVideoMetadata)
.ThenInclude(ovm => ovm.Tags)
.Include(ov => ov.LibraryPath)
.ThenInclude(lp => lp.Library)
.Include(ov => ov.MediaVersions)
.ThenInclude(ov => ov.MediaFiles)
.Include(ov => ov.MediaVersions)
.ThenInclude(ov => ov.Streams)
.Include(ov => ov.TraktListItems)
.ThenInclude(tli => tli.TraktList)
.OrderBy(i => i.MediaVersions.First().MediaFiles.First().Path)
.SingleOrDefaultAsync(i => i.MediaVersions.First().MediaFiles.First().Path == path);
return await maybeExisting.Match(
mediaItem =>
Right<BaseError, MediaItemScanResult<OtherVideo>>(
new MediaItemScanResult<OtherVideo>(mediaItem) { IsAdded = false }).AsTask(),
async () => await AddOtherVideo(dbContext, libraryPath.Id, path));
}
public Task<IEnumerable<string>> FindOtherVideoPaths(LibraryPath libraryPath) =>
_dbConnection.QueryAsync<string>(
@"SELECT MF.Path
FROM MediaFile MF
INNER JOIN MediaVersion MV on MF.MediaVersionId = MV.Id
INNER JOIN OtherVideo O on MV.OtherVideoId = O.Id
INNER JOIN MediaItem MI on O.Id = MI.Id
WHERE MI.LibraryPathId = @LibraryPathId",
new { LibraryPathId = libraryPath.Id });
public async Task<List<int>> DeleteByPath(LibraryPath libraryPath, string path)
{
List<int> ids = await _dbConnection.QueryAsync<int>(
@"SELECT O.Id
FROM OtherVideo O
INNER JOIN MediaItem MI on O.Id = MI.Id
INNER JOIN MediaVersion MV on O.Id = MV.OtherVideoId
INNER JOIN MediaFile MF on MV.Id = MF.MediaVersionId
WHERE MI.LibraryPathId = @LibraryPathId AND MF.Path = @Path",
new { LibraryPathId = libraryPath.Id, Path = path }).Map(result => result.ToList());
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
foreach (int otherVideoId in ids)
{
OtherVideo othervide = await dbContext.OtherVideos.FindAsync(otherVideoId);
dbContext.OtherVideos.Remove(othervide);
}
await dbContext.SaveChangesAsync();
return ids;
}
public Task<bool> AddTag(OtherVideoMetadata metadata, Tag tag) =>
_dbConnection.ExecuteAsync(
"INSERT INTO Tag (Name, OtherVideoMetadataId) VALUES (@Name, @MetadataId)",
new { tag.Name, MetadataId = metadata.Id }).Map(result => result > 0);
public async Task<List<OtherVideoMetadata>> GetOtherVideosForCards(List<int> ids)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
return await dbContext.OtherVideoMetadata
.AsNoTracking()
.Filter(ovm => ids.Contains(ovm.OtherVideoId))
.Include(ovm => ovm.OtherVideo)
.Include(ovm => ovm.Artwork)
.OrderBy(ovm => ovm.SortTitle)
.ToListAsync();
}
private static async Task<Either<BaseError, MediaItemScanResult<OtherVideo>>> AddOtherVideo(
TvContext dbContext,
int libraryPathId,
string path)
{
try
{
var otherVideo = new OtherVideo
{
LibraryPathId = libraryPathId,
MediaVersions = new List<MediaVersion>
{
new()
{
MediaFiles = new List<MediaFile>
{
new() { Path = path }
},
Streams = new List<MediaStream>()
}
},
TraktListItems = new List<TraktListItem>()
};
await dbContext.OtherVideos.AddAsync(otherVideo);
await dbContext.SaveChangesAsync();
await dbContext.Entry(otherVideo).Reference(m => m.LibraryPath).LoadAsync();
await dbContext.Entry(otherVideo.LibraryPath).Reference(lp => lp.Library).LoadAsync();
return new MediaItemScanResult<OtherVideo>(otherVideo) { IsAdded = true };
}
catch (Exception ex)
{
return BaseError.New(ex.Message);
}
}
}
}

4
ErsatzTV.Infrastructure/Data/Repositories/SearchRepository.cs

@ -97,6 +97,10 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -97,6 +97,10 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.ThenInclude(mm => mm.Styles)
.Include(mi => (mi as Artist).ArtistMetadata)
.ThenInclude(mm => mm.Moods)
.Include(mi => (mi as OtherVideo).OtherVideoMetadata)
.ThenInclude(mm => mm.Tags)
.Include(mi => (mi as OtherVideo).MediaVersions)
.ThenInclude(mm => mm.Streams)
.Include(mi => mi.TraktListItems)
.ThenInclude(tli => tli.TraktList)
.OrderBy(mi => mi.Id)

2
ErsatzTV.Infrastructure/Data/TvContext.cs

@ -39,6 +39,8 @@ namespace ErsatzTV.Infrastructure.Data @@ -39,6 +39,8 @@ namespace ErsatzTV.Infrastructure.Data
public DbSet<ArtistMetadata> ArtistMetadata { get; set; }
public DbSet<MusicVideo> MusicVideos { get; set; }
public DbSet<MusicVideoMetadata> MusicVideoMetadata { get; set; }
public DbSet<OtherVideo> OtherVideos { get; set; }
public DbSet<OtherVideoMetadata> OtherVideoMetadata { get; set; }
public DbSet<Show> Shows { get; set; }
public DbSet<ShowMetadata> ShowMetadata { get; set; }
public DbSet<Season> Seasons { get; set; }

3352
ErsatzTV.Infrastructure/Migrations/20211014025559_Add_LocalLibrary_OtherVideos.Designer.cs generated

File diff suppressed because it is too large Load Diff

24
ErsatzTV.Infrastructure/Migrations/20211014025559_Add_LocalLibrary_OtherVideos.cs

@ -0,0 +1,24 @@ @@ -0,0 +1,24 @@
using Microsoft.EntityFrameworkCore.Migrations;
namespace ErsatzTV.Infrastructure.Migrations
{
public partial class Add_LocalLibrary_OtherVideos : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
// create local other videos library
migrationBuilder.Sql(
@"INSERT INTO Library (Name, MediaKind, MediaSourceId)
SELECT 'Other Videos', 4, Id FROM
(SELECT LMS.Id FROM LocalMediaSource LMS
INNER JOIN Library L on L.MediaSourceId = LMS.Id
INNER JOIN LocalLibrary LL on L.Id = LL.Id
WHERE L.Name = 'Movies')");
migrationBuilder.Sql("INSERT INTO LocalLibrary (Id) Values (last_insert_rowid())");
}
protected override void Down(MigrationBuilder migrationBuilder)
{
}
}
}

3507
ErsatzTV.Infrastructure/Migrations/20211014123441_Add_OtherVideo_OtherVideoMetadata.Designer.cs generated

File diff suppressed because it is too large Load Diff

287
ErsatzTV.Infrastructure/Migrations/20211014123441_Add_OtherVideo_OtherVideoMetadata.cs

@ -0,0 +1,287 @@ @@ -0,0 +1,287 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
namespace ErsatzTV.Infrastructure.Migrations
{
public partial class Add_OtherVideo_OtherVideoMetadata : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "OtherVideoMetadataId",
table: "Tag",
type: "INTEGER",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "OtherVideoMetadataId",
table: "Studio",
type: "INTEGER",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "OtherVideoMetadataId",
table: "MetadataGuid",
type: "INTEGER",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "OtherVideoId",
table: "MediaVersion",
type: "INTEGER",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "OtherVideoMetadataId",
table: "Genre",
type: "INTEGER",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "OtherVideoMetadataId",
table: "Artwork",
type: "INTEGER",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "OtherVideoMetadataId",
table: "Actor",
type: "INTEGER",
nullable: true);
migrationBuilder.CreateTable(
name: "OtherVideo",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true)
},
constraints: table =>
{
table.PrimaryKey("PK_OtherVideo", x => x.Id);
table.ForeignKey(
name: "FK_OtherVideo_MediaItem_Id",
column: x => x.Id,
principalTable: "MediaItem",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "OtherVideoMetadata",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
OtherVideoId = table.Column<int>(type: "INTEGER", nullable: false),
MetadataKind = table.Column<int>(type: "INTEGER", nullable: false),
Title = table.Column<string>(type: "TEXT", nullable: true),
OriginalTitle = table.Column<string>(type: "TEXT", nullable: true),
SortTitle = table.Column<string>(type: "TEXT", nullable: true),
Year = table.Column<int>(type: "INTEGER", nullable: true),
ReleaseDate = table.Column<DateTime>(type: "TEXT", nullable: true),
DateAdded = table.Column<DateTime>(type: "TEXT", nullable: false),
DateUpdated = table.Column<DateTime>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_OtherVideoMetadata", x => x.Id);
table.ForeignKey(
name: "FK_OtherVideoMetadata_OtherVideo_OtherVideoId",
column: x => x.OtherVideoId,
principalTable: "OtherVideo",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_Tag_OtherVideoMetadataId",
table: "Tag",
column: "OtherVideoMetadataId");
migrationBuilder.CreateIndex(
name: "IX_Studio_OtherVideoMetadataId",
table: "Studio",
column: "OtherVideoMetadataId");
migrationBuilder.CreateIndex(
name: "IX_MetadataGuid_OtherVideoMetadataId",
table: "MetadataGuid",
column: "OtherVideoMetadataId");
migrationBuilder.CreateIndex(
name: "IX_MediaVersion_OtherVideoId",
table: "MediaVersion",
column: "OtherVideoId");
migrationBuilder.CreateIndex(
name: "IX_Genre_OtherVideoMetadataId",
table: "Genre",
column: "OtherVideoMetadataId");
migrationBuilder.CreateIndex(
name: "IX_Artwork_OtherVideoMetadataId",
table: "Artwork",
column: "OtherVideoMetadataId");
migrationBuilder.CreateIndex(
name: "IX_Actor_OtherVideoMetadataId",
table: "Actor",
column: "OtherVideoMetadataId");
migrationBuilder.CreateIndex(
name: "IX_OtherVideoMetadata_OtherVideoId",
table: "OtherVideoMetadata",
column: "OtherVideoId");
migrationBuilder.AddForeignKey(
name: "FK_Actor_OtherVideoMetadata_OtherVideoMetadataId",
table: "Actor",
column: "OtherVideoMetadataId",
principalTable: "OtherVideoMetadata",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
migrationBuilder.AddForeignKey(
name: "FK_Artwork_OtherVideoMetadata_OtherVideoMetadataId",
table: "Artwork",
column: "OtherVideoMetadataId",
principalTable: "OtherVideoMetadata",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
migrationBuilder.AddForeignKey(
name: "FK_Genre_OtherVideoMetadata_OtherVideoMetadataId",
table: "Genre",
column: "OtherVideoMetadataId",
principalTable: "OtherVideoMetadata",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
migrationBuilder.AddForeignKey(
name: "FK_MediaVersion_OtherVideo_OtherVideoId",
table: "MediaVersion",
column: "OtherVideoId",
principalTable: "OtherVideo",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
migrationBuilder.AddForeignKey(
name: "FK_MetadataGuid_OtherVideoMetadata_OtherVideoMetadataId",
table: "MetadataGuid",
column: "OtherVideoMetadataId",
principalTable: "OtherVideoMetadata",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
migrationBuilder.AddForeignKey(
name: "FK_Studio_OtherVideoMetadata_OtherVideoMetadataId",
table: "Studio",
column: "OtherVideoMetadataId",
principalTable: "OtherVideoMetadata",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
migrationBuilder.AddForeignKey(
name: "FK_Tag_OtherVideoMetadata_OtherVideoMetadataId",
table: "Tag",
column: "OtherVideoMetadataId",
principalTable: "OtherVideoMetadata",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_Actor_OtherVideoMetadata_OtherVideoMetadataId",
table: "Actor");
migrationBuilder.DropForeignKey(
name: "FK_Artwork_OtherVideoMetadata_OtherVideoMetadataId",
table: "Artwork");
migrationBuilder.DropForeignKey(
name: "FK_Genre_OtherVideoMetadata_OtherVideoMetadataId",
table: "Genre");
migrationBuilder.DropForeignKey(
name: "FK_MediaVersion_OtherVideo_OtherVideoId",
table: "MediaVersion");
migrationBuilder.DropForeignKey(
name: "FK_MetadataGuid_OtherVideoMetadata_OtherVideoMetadataId",
table: "MetadataGuid");
migrationBuilder.DropForeignKey(
name: "FK_Studio_OtherVideoMetadata_OtherVideoMetadataId",
table: "Studio");
migrationBuilder.DropForeignKey(
name: "FK_Tag_OtherVideoMetadata_OtherVideoMetadataId",
table: "Tag");
migrationBuilder.DropTable(
name: "OtherVideoMetadata");
migrationBuilder.DropTable(
name: "OtherVideo");
migrationBuilder.DropIndex(
name: "IX_Tag_OtherVideoMetadataId",
table: "Tag");
migrationBuilder.DropIndex(
name: "IX_Studio_OtherVideoMetadataId",
table: "Studio");
migrationBuilder.DropIndex(
name: "IX_MetadataGuid_OtherVideoMetadataId",
table: "MetadataGuid");
migrationBuilder.DropIndex(
name: "IX_MediaVersion_OtherVideoId",
table: "MediaVersion");
migrationBuilder.DropIndex(
name: "IX_Genre_OtherVideoMetadataId",
table: "Genre");
migrationBuilder.DropIndex(
name: "IX_Artwork_OtherVideoMetadataId",
table: "Artwork");
migrationBuilder.DropIndex(
name: "IX_Actor_OtherVideoMetadataId",
table: "Actor");
migrationBuilder.DropColumn(
name: "OtherVideoMetadataId",
table: "Tag");
migrationBuilder.DropColumn(
name: "OtherVideoMetadataId",
table: "Studio");
migrationBuilder.DropColumn(
name: "OtherVideoMetadataId",
table: "MetadataGuid");
migrationBuilder.DropColumn(
name: "OtherVideoId",
table: "MediaVersion");
migrationBuilder.DropColumn(
name: "OtherVideoMetadataId",
table: "Genre");
migrationBuilder.DropColumn(
name: "OtherVideoMetadataId",
table: "Artwork");
migrationBuilder.DropColumn(
name: "OtherVideoMetadataId",
table: "Actor");
}
}
}

157
ErsatzTV.Infrastructure/Migrations/TvContextModelSnapshot.cs

@ -14,7 +14,7 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -14,7 +14,7 @@ namespace ErsatzTV.Infrastructure.Migrations
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "5.0.10");
.HasAnnotation("ProductVersion", "5.0.11");
modelBuilder.Entity("ErsatzTV.Core.Domain.Actor", b =>
{
@ -43,6 +43,9 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -43,6 +43,9 @@ namespace ErsatzTV.Infrastructure.Migrations
b.Property<int?>("Order")
.HasColumnType("INTEGER");
b.Property<int?>("OtherVideoMetadataId")
.HasColumnType("INTEGER");
b.Property<string>("Role")
.HasColumnType("TEXT");
@ -65,6 +68,8 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -65,6 +68,8 @@ namespace ErsatzTV.Infrastructure.Migrations
b.HasIndex("MusicVideoMetadataId");
b.HasIndex("OtherVideoMetadataId");
b.HasIndex("SeasonMetadataId");
b.HasIndex("ShowMetadataId");
@ -151,6 +156,9 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -151,6 +156,9 @@ namespace ErsatzTV.Infrastructure.Migrations
b.Property<int?>("MusicVideoMetadataId")
.HasColumnType("INTEGER");
b.Property<int?>("OtherVideoMetadataId")
.HasColumnType("INTEGER");
b.Property<string>("Path")
.HasColumnType("TEXT");
@ -172,6 +180,8 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -172,6 +180,8 @@ namespace ErsatzTV.Infrastructure.Migrations
b.HasIndex("MusicVideoMetadataId");
b.HasIndex("OtherVideoMetadataId");
b.HasIndex("SeasonMetadataId");
b.HasIndex("ShowMetadataId");
@ -525,6 +535,9 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -525,6 +535,9 @@ namespace ErsatzTV.Infrastructure.Migrations
b.Property<string>("Name")
.HasColumnType("TEXT");
b.Property<int?>("OtherVideoMetadataId")
.HasColumnType("INTEGER");
b.Property<int?>("SeasonMetadataId")
.HasColumnType("INTEGER");
@ -541,6 +554,8 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -541,6 +554,8 @@ namespace ErsatzTV.Infrastructure.Migrations
b.HasIndex("MusicVideoMetadataId");
b.HasIndex("OtherVideoMetadataId");
b.HasIndex("SeasonMetadataId");
b.HasIndex("ShowMetadataId");
@ -815,6 +830,9 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -815,6 +830,9 @@ namespace ErsatzTV.Infrastructure.Migrations
b.Property<string>("Name")
.HasColumnType("TEXT");
b.Property<int?>("OtherVideoId")
.HasColumnType("INTEGER");
b.Property<string>("RFrameRate")
.HasColumnType("TEXT");
@ -835,6 +853,8 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -835,6 +853,8 @@ namespace ErsatzTV.Infrastructure.Migrations
b.HasIndex("MusicVideoId");
b.HasIndex("OtherVideoId");
b.ToTable("MediaVersion");
});
@ -859,6 +879,9 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -859,6 +879,9 @@ namespace ErsatzTV.Infrastructure.Migrations
b.Property<int?>("MusicVideoMetadataId")
.HasColumnType("INTEGER");
b.Property<int?>("OtherVideoMetadataId")
.HasColumnType("INTEGER");
b.Property<int?>("SeasonMetadataId")
.HasColumnType("INTEGER");
@ -875,6 +898,8 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -875,6 +898,8 @@ namespace ErsatzTV.Infrastructure.Migrations
b.HasIndex("MusicVideoMetadataId");
b.HasIndex("OtherVideoMetadataId");
b.HasIndex("SeasonMetadataId");
b.HasIndex("ShowMetadataId");
@ -1055,6 +1080,46 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -1055,6 +1080,46 @@ namespace ErsatzTV.Infrastructure.Migrations
b.ToTable("MusicVideoMetadata");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.OtherVideoMetadata", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("DateAdded")
.HasColumnType("TEXT");
b.Property<DateTime>("DateUpdated")
.HasColumnType("TEXT");
b.Property<int>("MetadataKind")
.HasColumnType("INTEGER");
b.Property<string>("OriginalTitle")
.HasColumnType("TEXT");
b.Property<int>("OtherVideoId")
.HasColumnType("INTEGER");
b.Property<DateTime?>("ReleaseDate")
.HasColumnType("TEXT");
b.Property<string>("SortTitle")
.HasColumnType("TEXT");
b.Property<string>("Title")
.HasColumnType("TEXT");
b.Property<int?>("Year")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("OtherVideoId");
b.ToTable("OtherVideoMetadata");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.Playout", b =>
{
b.Property<int>("Id")
@ -1436,6 +1501,9 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -1436,6 +1501,9 @@ namespace ErsatzTV.Infrastructure.Migrations
b.Property<string>("Name")
.HasColumnType("TEXT");
b.Property<int?>("OtherVideoMetadataId")
.HasColumnType("INTEGER");
b.Property<int?>("SeasonMetadataId")
.HasColumnType("INTEGER");
@ -1452,6 +1520,8 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -1452,6 +1520,8 @@ namespace ErsatzTV.Infrastructure.Migrations
b.HasIndex("MusicVideoMetadataId");
b.HasIndex("OtherVideoMetadataId");
b.HasIndex("SeasonMetadataId");
b.HasIndex("ShowMetadataId");
@ -1499,6 +1569,9 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -1499,6 +1569,9 @@ namespace ErsatzTV.Infrastructure.Migrations
b.Property<string>("Name")
.HasColumnType("TEXT");
b.Property<int?>("OtherVideoMetadataId")
.HasColumnType("INTEGER");
b.Property<int?>("SeasonMetadataId")
.HasColumnType("INTEGER");
@ -1515,6 +1588,8 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -1515,6 +1588,8 @@ namespace ErsatzTV.Infrastructure.Migrations
b.HasIndex("MusicVideoMetadataId");
b.HasIndex("OtherVideoMetadataId");
b.HasIndex("SeasonMetadataId");
b.HasIndex("ShowMetadataId");
@ -1733,6 +1808,13 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -1733,6 +1808,13 @@ namespace ErsatzTV.Infrastructure.Migrations
b.ToTable("MusicVideo");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.OtherVideo", b =>
{
b.HasBaseType("ErsatzTV.Core.Domain.MediaItem");
b.ToTable("OtherVideo");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.Season", b =>
{
b.HasBaseType("ErsatzTV.Core.Domain.MediaItem");
@ -2039,6 +2121,10 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -2039,6 +2121,10 @@ namespace ErsatzTV.Infrastructure.Migrations
.WithMany("Actors")
.HasForeignKey("MusicVideoMetadataId");
b.HasOne("ErsatzTV.Core.Domain.OtherVideoMetadata", null)
.WithMany("Actors")
.HasForeignKey("OtherVideoMetadataId");
b.HasOne("ErsatzTV.Core.Domain.SeasonMetadata", null)
.WithMany("Actors")
.HasForeignKey("SeasonMetadataId");
@ -2089,6 +2175,11 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -2089,6 +2175,11 @@ namespace ErsatzTV.Infrastructure.Migrations
.HasForeignKey("MusicVideoMetadataId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("ErsatzTV.Core.Domain.OtherVideoMetadata", null)
.WithMany("Artwork")
.HasForeignKey("OtherVideoMetadataId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("ErsatzTV.Core.Domain.SeasonMetadata", null)
.WithMany("Artwork")
.HasForeignKey("SeasonMetadataId")
@ -2214,6 +2305,10 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -2214,6 +2305,10 @@ namespace ErsatzTV.Infrastructure.Migrations
.HasForeignKey("MusicVideoMetadataId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("ErsatzTV.Core.Domain.OtherVideoMetadata", null)
.WithMany("Genres")
.HasForeignKey("OtherVideoMetadataId");
b.HasOne("ErsatzTV.Core.Domain.SeasonMetadata", null)
.WithMany("Genres")
.HasForeignKey("SeasonMetadataId");
@ -2328,6 +2423,11 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -2328,6 +2423,11 @@ namespace ErsatzTV.Infrastructure.Migrations
.WithMany("MediaVersions")
.HasForeignKey("MusicVideoId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("ErsatzTV.Core.Domain.OtherVideo", null)
.WithMany("MediaVersions")
.HasForeignKey("OtherVideoId")
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity("ErsatzTV.Core.Domain.MetadataGuid", b =>
@ -2350,6 +2450,10 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -2350,6 +2450,10 @@ namespace ErsatzTV.Infrastructure.Migrations
.WithMany("Guids")
.HasForeignKey("MusicVideoMetadataId");
b.HasOne("ErsatzTV.Core.Domain.OtherVideoMetadata", null)
.WithMany("Guids")
.HasForeignKey("OtherVideoMetadataId");
b.HasOne("ErsatzTV.Core.Domain.SeasonMetadata", null)
.WithMany("Guids")
.HasForeignKey("SeasonMetadataId")
@ -2429,6 +2533,17 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -2429,6 +2533,17 @@ namespace ErsatzTV.Infrastructure.Migrations
b.Navigation("MusicVideo");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.OtherVideoMetadata", b =>
{
b.HasOne("ErsatzTV.Core.Domain.OtherVideo", "OtherVideo")
.WithMany("OtherVideoMetadata")
.HasForeignKey("OtherVideoId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("OtherVideo");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.Playout", b =>
{
b.HasOne("ErsatzTV.Core.Domain.Channel", "Channel")
@ -2679,6 +2794,10 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -2679,6 +2794,10 @@ namespace ErsatzTV.Infrastructure.Migrations
.HasForeignKey("MusicVideoMetadataId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("ErsatzTV.Core.Domain.OtherVideoMetadata", null)
.WithMany("Studios")
.HasForeignKey("OtherVideoMetadataId");
b.HasOne("ErsatzTV.Core.Domain.SeasonMetadata", null)
.WithMany("Studios")
.HasForeignKey("SeasonMetadataId");
@ -2717,6 +2836,11 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -2717,6 +2836,11 @@ namespace ErsatzTV.Infrastructure.Migrations
.HasForeignKey("MusicVideoMetadataId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("ErsatzTV.Core.Domain.OtherVideoMetadata", null)
.WithMany("Tags")
.HasForeignKey("OtherVideoMetadataId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("ErsatzTV.Core.Domain.SeasonMetadata", null)
.WithMany("Tags")
.HasForeignKey("SeasonMetadataId");
@ -2866,6 +2990,15 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -2866,6 +2990,15 @@ namespace ErsatzTV.Infrastructure.Migrations
b.Navigation("Artist");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.OtherVideo", b =>
{
b.HasOne("ErsatzTV.Core.Domain.MediaItem", null)
.WithOne()
.HasForeignKey("ErsatzTV.Core.Domain.OtherVideo", "Id")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("ErsatzTV.Core.Domain.Season", b =>
{
b.HasOne("ErsatzTV.Core.Domain.MediaItem", null)
@ -3223,6 +3356,21 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -3223,6 +3356,21 @@ namespace ErsatzTV.Infrastructure.Migrations
b.Navigation("Tags");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.OtherVideoMetadata", b =>
{
b.Navigation("Actors");
b.Navigation("Artwork");
b.Navigation("Genres");
b.Navigation("Guids");
b.Navigation("Studios");
b.Navigation("Tags");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.Playout", b =>
{
b.Navigation("Items");
@ -3310,6 +3458,13 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -3310,6 +3458,13 @@ namespace ErsatzTV.Infrastructure.Migrations
b.Navigation("MusicVideoMetadata");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.OtherVideo", b =>
{
b.Navigation("MediaVersions");
b.Navigation("OtherVideoMetadata");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.Season", b =>
{
b.Navigation("Episodes");

46
ErsatzTV.Infrastructure/Search/SearchIndex.cs

@ -60,6 +60,7 @@ namespace ErsatzTV.Infrastructure.Search @@ -60,6 +60,7 @@ namespace ErsatzTV.Infrastructure.Search
public const string ArtistType = "artist";
public const string MusicVideoType = "music_video";
public const string EpisodeType = "episode";
public const string OtherVideoType = "other_video";
private readonly List<CultureInfo> _cultureInfos;
private readonly ILogger<SearchIndex> _logger;
@ -121,6 +122,9 @@ namespace ErsatzTV.Infrastructure.Search @@ -121,6 +122,9 @@ namespace ErsatzTV.Infrastructure.Search
case Episode episode:
await UpdateEpisode(searchRepository, episode);
break;
case OtherVideo otherVideo:
await UpdateOtherVideo(searchRepository, otherVideo);
break;
}
}
@ -218,6 +222,9 @@ namespace ErsatzTV.Infrastructure.Search @@ -218,6 +222,9 @@ namespace ErsatzTV.Infrastructure.Search
case Episode episode:
await UpdateEpisode(searchRepository, episode);
break;
case OtherVideo otherVideo:
await UpdateOtherVideo(searchRepository, otherVideo);
break;
}
}
}
@ -747,6 +754,44 @@ namespace ErsatzTV.Infrastructure.Search @@ -747,6 +754,44 @@ namespace ErsatzTV.Infrastructure.Search
}
}
}
private async Task UpdateOtherVideo(ISearchRepository searchRepository, OtherVideo otherVideo)
{
Option<OtherVideoMetadata> maybeMetadata = otherVideo.OtherVideoMetadata.HeadOrNone();
if (maybeMetadata.IsSome)
{
OtherVideoMetadata metadata = maybeMetadata.ValueUnsafe();
try
{
var doc = new Document
{
new StringField(IdField, otherVideo.Id.ToString(), Field.Store.YES),
new StringField(TypeField, OtherVideoType, Field.Store.YES),
new TextField(TitleField, metadata.Title, Field.Store.NO),
new StringField(SortTitleField, metadata.SortTitle.ToLowerInvariant(), Field.Store.NO),
new TextField(LibraryNameField, otherVideo.LibraryPath.Library.Name, Field.Store.NO),
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)
};
await AddLanguages(searchRepository, doc, otherVideo.MediaVersions);
foreach (Tag tag in metadata.Tags)
{
doc.Add(new TextField(TagField, tag.Name, Field.Store.NO));
}
_writer.UpdateDocument(new Term(IdField, otherVideo.Id.ToString()), doc);
}
catch (Exception ex)
{
metadata.OtherVideo = null;
_logger.LogWarning(ex, "Error indexing other video with metadata {@Metadata}", metadata);
}
}
}
private SearchItem ProjectToSearchItem(Document doc) => new(
doc.Get(TypeField),
@ -773,6 +818,7 @@ namespace ErsatzTV.Infrastructure.Search @@ -773,6 +818,7 @@ namespace ErsatzTV.Infrastructure.Search
EpisodeMetadata em =>
$"{em.Title}_{em.Year}_{em.Episode.Season.SeasonNumber}_{em.EpisodeNumber}"
.ToLowerInvariant(),
OtherVideoMetadata ovm => $"{ovm.OriginalTitle}".ToLowerInvariant(),
_ => $"{metadata.Title}_{metadata.Year}".ToLowerInvariant()
};

42
ErsatzTV/Pages/CollectionItems.razor

@ -59,6 +59,10 @@ @@ -59,6 +59,10 @@
{
<MudLink Class="ml-4" Href="@(_navigationManager.Uri.Split("#").Head() + "#music_videos")">@_data.MusicVideoCards.Count Music Videos</MudLink>
}
@if (_data.OtherVideoCards.Any())
{
<MudLink Class="ml-4" Href="@(_navigationManager.Uri.Split("#").Head() + "#other_videos")">@_data.OtherVideoCards.Count Other Videos</MudLink>
}
@if (SupportsCustomOrdering())
{
<div style="margin-left: auto">
@ -220,6 +224,30 @@ @@ -220,6 +224,30 @@
}
</MudContainer>
}
@if (_data.OtherVideoCards.Any())
{
<MudText GutterBottom="true"
Typo="Typo.h4"
Style="scroll-margin-top: 160px"
UserAttributes="@(new Dictionary<string, object> { { "id", "other_videos" } })">
Other Videos
</MudText>
<MudContainer MaxWidth="MaxWidth.False" Class="media-card-grid">
@foreach (OtherVideoCardViewModel card in _data.OtherVideoCards.OrderBy(e => e.SortTitle))
{
<MediaCard Data="@card"
Link=""
ArtworkKind="ArtworkKind.Thumbnail"
DeleteClicked="@RemoveOtherVideoFromCollection"
SelectColor="@Color.Error"
SelectClicked="@(e => SelectClicked(card, e))"
IsSelected="@IsSelected(card)"
IsSelectMode="@IsSelectMode()"/>
}
</MudContainer>
}
</MudContainer>
@code {
@ -278,6 +306,7 @@ @@ -278,6 +306,7 @@
.Append(_data.EpisodeCards.OrderBy(ep => ep.Aired))
.Append(_data.ArtistCards.OrderBy(a => a.SortTitle))
.Append(_data.MusicVideoCards.OrderBy(mv => mv.SortTitle))
.Append(_data.OtherVideoCards.OrderBy(ov => ov.SortTitle))
.ToList();
}
@ -361,6 +390,19 @@ @@ -361,6 +390,19 @@
await RemoveItemsWithConfirmation("episode", $"{episode.ShowTitle} - {episode.Title}", request);
}
private async Task RemoveOtherVideoFromCollection(MediaCardViewModel vm)
{
if (vm is OtherVideoCardViewModel otherVideo)
{
var request = new RemoveItemsFromCollection(Id)
{
MediaItemIds = new List<int> { otherVideo.OtherVideoId }
};
await RemoveItemsWithConfirmation("other video", $"{otherVideo.Title}", request);
}
}
private async Task RemoveItemsWithConfirmation(
string entityType,

9
ErsatzTV/Pages/MultiSelectBase.cs

@ -98,7 +98,8 @@ namespace ErsatzTV.Pages @@ -98,7 +98,8 @@ namespace ErsatzTV.Pages
_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<MusicVideoCardViewModel>().Map(mv => mv.MusicVideoId).ToList(),
_selectedItems.OfType<OtherVideoCardViewModel>().Map(ov => ov.OtherVideoId).ToList());
protected async Task AddItemsToCollection(
List<int> movieIds,
@ -107,10 +108,11 @@ namespace ErsatzTV.Pages @@ -107,10 +108,11 @@ namespace ErsatzTV.Pages
List<int> episodeIds,
List<int> artistIds,
List<int> musicVideoIds,
List<int> otherVideoIds,
string entityName = "selected items")
{
int count = movieIds.Count + showIds.Count + seasonIds.Count + episodeIds.Count + artistIds.Count +
musicVideoIds.Count;
musicVideoIds.Count + otherVideoIds.Count;
var parameters = new DialogParameters
{ { "EntityType", count.ToString() }, { "EntityName", entityName } };
@ -127,7 +129,8 @@ namespace ErsatzTV.Pages @@ -127,7 +129,8 @@ namespace ErsatzTV.Pages
seasonIds,
episodeIds,
artistIds,
musicVideoIds);
musicVideoIds,
otherVideoIds);
Either<BaseError, Unit> addResult = await Mediator.Send(request);
addResult.Match(

158
ErsatzTV/Pages/OtherVideoList.razor

@ -0,0 +1,158 @@ @@ -0,0 +1,158 @@
@page "/media/other/videos"
@page "/media/other/videos/page/{PageNumber:int}"
@using LanguageExt.UnsafeValueAccess
@using ErsatzTV.Application.MediaCards
@using ErsatzTV.Application.MediaCollections
@using ErsatzTV.Application.MediaCollections.Commands
@using ErsatzTV.Application.Search.Queries
@using ErsatzTV.Extensions
@using Unit = LanguageExt.Unit
@inherits MultiSelectBase<OtherVideoList>
@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.Primary"
StartIcon="@Icons.Material.Filled.Add"
OnClick="@(_ => AddSelectionToCollection())">
Add To Collection
</MudButton>
<MudButton Class="ml-3"
Variant="Variant.Filled"
Color="Color.Secondary"
StartIcon="@Icons.Material.Filled.Check"
OnClick="@(_ => ClearSelection())">
Clear Selection
</MudButton>
</div>
}
else
{
<MudText Style="margin-bottom: auto; margin-top: auto; width: 33%">@_query</MudText>
<div style="max-width: 300px; width: 33%;">
<MudPaper Style="align-items: center; display: flex; justify-content: center;">
<MudIconButton Icon="@Icons.Material.Outlined.ChevronLeft"
OnClick="@PrevPage"
Disabled="@(PageNumber <= 1)">
</MudIconButton>
<MudText Style="flex-grow: 1"
Align="Align.Center">
@Math.Min((PageNumber - 1) * PageSize + 1, _data.Count)-@Math.Min(_data.Count, PageNumber * PageSize) of @_data.Count
</MudText>
<MudIconButton Icon="@Icons.Material.Outlined.ChevronRight"
OnClick="@NextPage" Disabled="@(PageNumber * PageSize >= _data.Count)">
</MudIconButton>
</MudPaper>
</div>
}
</div>
</MudPaper>
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="pt-8" Style="margin-top: 64px">
<MudContainer MaxWidth="MaxWidth.False" Class="media-card-grid">
<FragmentLetterAnchor TCard="OtherVideoCardViewModel" Cards="@_data.Cards">
<MediaCard Data="@context"
Link=""
ArtworkKind="ArtworkKind.Thumbnail"
AddToCollectionClicked="@AddToCollection"
SelectClicked="@(e => SelectClicked(context, e))"
IsSelected="@IsSelected(context)"
IsSelectMode="@IsSelectMode()"/>
</FragmentLetterAnchor>
</MudContainer>
</MudContainer>
@if (_data.PageMap.IsSome)
{
<LetterBar PageMap="@_data.PageMap.ValueUnsafe()"
BaseUri="/media/other/videos"
Query="@_query"/>
}
@code {
private static int PageSize => 100;
[Parameter]
public int PageNumber { get; set; }
private OtherVideoCardResultsViewModel _data;
private string _query;
protected override Task OnParametersSetAsync()
{
if (PageNumber == 0)
{
PageNumber = 1;
}
_query = _navigationManager.Uri.GetSearchQuery();
return RefreshData();
}
protected override async Task RefreshData()
{
string searchQuery = string.IsNullOrWhiteSpace(_query) ? "type:other_video" : $"type:other_video AND ({_query})";
_data = await Mediator.Send(new QuerySearchIndexOtherVideos(searchQuery, PageNumber, PageSize));
}
private void PrevPage()
{
var uri = $"/media/other/videos/page/{PageNumber - 1}";
if (!string.IsNullOrWhiteSpace(_query))
{
(string key, string value) = _query.EncodeQuery();
uri = $"{uri}?{key}={value}";
}
_navigationManager.NavigateTo(uri);
}
private void NextPage()
{
var uri = $"/media/other/videos/page/{PageNumber + 1}";
if (!string.IsNullOrWhiteSpace(_query))
{
(string key, string value) = _query.EncodeQuery();
uri = $"{uri}?{key}={value}";
}
_navigationManager.NavigateTo(uri);
}
private void SelectClicked(MediaCardViewModel card, MouseEventArgs e)
{
List<MediaCardViewModel> GetSortedItems()
{
return _data.Cards.OrderBy(m => m.SortTitle).ToList<MediaCardViewModel>();
}
SelectClicked(GetSortedItems, card, e);
}
private async Task AddToCollection(MediaCardViewModel card)
{
if (card is OtherVideoCardViewModel otherVideo)
{
var parameters = new DialogParameters { { "EntityType", "other video" }, { "EntityName", otherVideo.Title } };
var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall };
IDialogReference dialog = Dialog.Show<AddToCollectionDialog>("Add To Collection", parameters, options);
DialogResult result = await dialog.Result;
if (!result.Cancelled && result.Data is MediaCollectionViewModel collection)
{
var request = new AddOtherVideoToCollection(collection.Id, otherVideo.OtherVideoId);
Either<BaseError, Unit> addResult = await Mediator.Send(request);
addResult.Match(
Left: error =>
{
Snackbar.Add($"Unexpected error adding other video to collection: {error.Value}");
Logger.LogError("Unexpected error adding other video to collection: {Error}", error.Value);
},
Right: _ => Snackbar.Add($"Added {otherVideo.Title} to collection {collection.Name}", Severity.Success));
}
}
}
}

69
ErsatzTV/Pages/Search.razor

@ -63,6 +63,11 @@ @@ -63,6 +63,11 @@
{
<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>
}
<div style="margin-left: auto">
<MudTooltip Text="Add All To Collection">
<MudButton Variant="Variant.Filled"
@ -249,6 +254,34 @@ @@ -249,6 +254,34 @@
}
</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"
AddToCollectionClicked="@AddToCollection"
SelectClicked="@(e => SelectClicked(card, e))"
IsSelected="@IsSelected(card)"
IsSelectMode="@IsSelectMode()"/>
}
</MudContainer>
}
</MudContainer>
@code {
@ -258,6 +291,7 @@ @@ -258,6 +291,7 @@
private TelevisionSeasonCardResultsViewModel _seasons;
private TelevisionEpisodeCardResultsViewModel _episodes;
private MusicVideoCardResultsViewModel _musicVideos;
private OtherVideoCardResultsViewModel _otherVideos;
private ArtistCardResultsViewModel _artists;
protected override async Task OnInitializedAsync()
@ -270,6 +304,7 @@ @@ -270,6 +304,7 @@
_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));
_artists = await Mediator.Send(new QuerySearchIndexArtists($"type:artist AND ({_query})", 1, 50));
}
}
@ -284,6 +319,7 @@ @@ -284,6 +319,7 @@
.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))
.ToList();
}
@ -396,6 +432,27 @@ @@ -396,6 +432,27 @@
Right: _ => Snackbar.Add($"Added {musicVideo.Title} to collection {collection.Name}", Severity.Success));
}
}
if (card is OtherVideoCardViewModel otherVideo)
{
var parameters = new DialogParameters { { "EntityType", "other video" }, { "EntityName", otherVideo.Title } };
var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall };
IDialogReference dialog = Dialog.Show<AddToCollectionDialog>("Add To Collection", parameters, options);
DialogResult result = await dialog.Result;
if (!result.Cancelled && result.Data is MediaCollectionViewModel collection)
{
var request = new AddOtherVideoToCollection(collection.Id, otherVideo.OtherVideoId);
Either<BaseError, Unit> addResult = await Mediator.Send(request);
addResult.Match(
Left: error =>
{
Snackbar.Add($"Unexpected error adding other video to collection: {error.Value}");
Logger.LogError("Unexpected error adding other video to collection: {Error}", error.Value);
},
Right: _ => Snackbar.Add($"Added {otherVideo.Title} to collection {collection.Name}", Severity.Success));
}
}
}
private string GetMoviesLink()
@ -463,6 +520,17 @@ @@ -463,6 +520,17 @@
}
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 async Task AddAllToCollection(MouseEventArgs _)
{
@ -474,6 +542,7 @@ @@ -474,6 +542,7 @@
results.EpisodeIds,
results.ArtistIds,
results.MusicVideoIds,
results.OtherVideoIds,
"search results");
}

3
ErsatzTV/Shared/MainLayout.razor

@ -52,7 +52,8 @@ @@ -52,7 +52,8 @@
<MudNavLink Href="/media/libraries">Libraries</MudNavLink>
<MudNavLink Href="/media/tv/shows">TV Shows</MudNavLink>
<MudNavLink Href="/media/movies">Movies</MudNavLink>
<MudNavLink Href="/media/music/artists">Music</MudNavLink>
<MudNavLink Href="/media/music/artists">Music Videos</MudNavLink>
<MudNavLink Href="/media/other/videos">Other Videos</MudNavLink>
</MudNavGroup>
<MudNavGroup Title="Lists" Expanded="true">
<MudNavLink Href="/media/collections">Collections</MudNavLink>

2
ErsatzTV/Startup.cs

@ -271,6 +271,7 @@ namespace ErsatzTV @@ -271,6 +271,7 @@ namespace ErsatzTV
services.AddScoped<IMovieRepository, MovieRepository>();
services.AddScoped<IArtistRepository, ArtistRepository>();
services.AddScoped<IMusicVideoRepository, MusicVideoRepository>();
services.AddScoped<IOtherVideoRepository, OtherVideoRepository>();
services.AddScoped<ILibraryRepository, LibraryRepository>();
services.AddScoped<IMetadataRepository, MetadataRepository>();
services.AddScoped<IArtworkRepository, ArtworkRepository>();
@ -284,6 +285,7 @@ namespace ErsatzTV @@ -284,6 +285,7 @@ namespace ErsatzTV
services.AddScoped<IMovieFolderScanner, MovieFolderScanner>();
services.AddScoped<ITelevisionFolderScanner, TelevisionFolderScanner>();
services.AddScoped<IMusicVideoFolderScanner, MusicVideoFolderScanner>();
services.AddScoped<IOtherVideoFolderScanner, OtherVideoFolderScanner>();
services.AddScoped<IPlexMovieLibraryScanner, PlexMovieLibraryScanner>();
services.AddScoped<IPlexTelevisionLibraryScanner, PlexTelevisionLibraryScanner>();
services.AddScoped<IPlexServerApiClient, PlexServerApiClient>();

Loading…
Cancel
Save