Compare commits

...
Author SHA1 Message Date
Jason DoveandGitHub 24f2b4b727 force music video library scan (#159) 2021-04-09 07:42:13 -05:00
Jason DoveandGitHub 667887f387 fix television show and season playouts (#158) 2021-04-09 07:24:12 -05:00
Jason Dove 98eb72fcfe skip docker with [no docker] 2021-04-09 06:42:16 -05:00
Jason Dove c2f92fd054 add github release links to docs [no ci] 2021-04-09 06:39:22 -05:00
Jason DoveandGitHub f04ddd3a40 save collection page size (#157) 2021-04-09 06:30:46 -05:00
Jason DoveandGitHub aa0942384d fix removing deleted music videos (#156) 2021-04-09 05:34:28 -05:00
Jason DoveandGitHub cd100be3a2 relax music video naming requirements (#155) 2021-04-09 05:22:02 -05:00
Jason DoveandGitHub 2b26a5411c add artists as owners of music videos (#154)
* clean up genre, tag, studio orphans

* enforce foreign keys at connection level

* wip

* fix fragment scroll offset

* fix see all link for music videos

* add fake artist metadata

* not null artist id

* add artist scanning

* remove improperly named music videos

* code cleanup

* add artists to search results and collections

* clean up music video metadata / artist

* add artist view

* show music videos on artist page

* add music video artwork placeholder
2021-04-09 05:10:58 -05:00
Jason DoveandGitHub baf81f31cd fix plex server and connection sync (#153) 2021-04-08 18:48:12 -05:00
Jason DoveandGitHub bfa290790b trim parsed music video titles (#152) 2021-04-07 16:36:20 -05:00
Jason DoveandGitHub b975922a77 only index video stream languages for movies and music videos (#151) 2021-04-07 16:12:39 -05:00
Jason Dove 1a39978a77 try to fix windows builds 2021-04-07 05:34:02 -05:00
Jason DoveandGitHub 436c9119fa add all search results to collection (#150) 2021-04-06 20:43:44 -05:00
Jason Dove 33642a13ce allow manual ci trigger 2021-04-06 09:21:54 -05:00
Jason Dove 09b349d1cb only index audio language [no ci] 2021-04-06 09:11:39 -05:00
Jason DoveandGitHub 2be729c10e search show by language (#149) 2021-04-06 07:58:45 -05:00
Jason DoveandGitHub 0aac702853 search movies and music videos by language (#148) 2021-04-06 07:42:21 -05:00
Jason DoveandGitHub 3f406ac556 log viewer improvements (#147) 2021-04-06 07:25:05 -05:00
Jason DoveandGitHub 454e2edf7c add documentation link to ui (#146) 2021-04-06 06:39:48 -05:00
b3f4fa8c23 add documentation (#145)
* start to reorganize documentation

* revert readme tag changes

* revert readme tag changes pt2

* doc updates; doc theme updates

* doc updates

* publish docs from documentation branch

* use favicon for docs

* create channel

* collections, jellyfin client, schedule items and playout docs

* channels dvr

* tivimate

* scale tivimate screenshots

* add channels dvr server setup

* add copyright and social

* Added UnRAID Docker install, formatting fixes (#100)

Co-authored-by: Thaddeus Cooper <redacted@redacted.co.nz>

* minor doc updates

* readme tweak

* add basic plex documentation

Co-authored-by: suckerface <9060047+suckerface@users.noreply.github.com>
Co-authored-by: Thaddeus Cooper <redacted@redacted.co.nz>
2021-04-06 06:20:49 -05:00
Jason DoveandGitHub a6496db58d settings rework (#144)
* add hdhr tuner count setting

* code cleanup
2021-04-05 19:48:17 -05:00
Jason Dove 3eed79b5e1 Merge branch 'main' of github.com:jasongdove/ErsatzTV 2021-04-05 16:19:22 -05:00
Jason DoveandGitHub 79bfba6428 better search index thread fix (#143)
* Revert "fix search index threading (#141)"

This reverts commit 3fb6da0754.

* better search index thread fix
2021-04-05 16:18:17 -05:00
Jason Dove 9f6d4114a6 Merge branch 'main' of github.com:jasongdove/ErsatzTV 2021-04-05 16:06:28 -05:00
Jason DoveandGitHub 9809c60924 send all audio streams on hls channels with no preferred language (#142)
* Revert "fix search index threading (#141)"

This reverts commit 3fb6da0754.

* send all audio streams on hls channels with no preferred language
2021-04-05 16:06:13 -05:00
Jason Dove 16072fed1c Revert "fix search index threading (#141)"
This reverts commit 3fb6da0754.
2021-04-05 07:44:42 -05:00
Jason DoveandGitHub 3fb6da0754 fix search index threading (#141)
* fix search index threading

* code cleanup
2021-04-05 05:41:29 -05:00
Jason DoveandGitHub 24cdf6295f clean up fragment letter anchor code (#140) 2021-04-04 20:56:47 -05:00
Jason DoveandGitHub c1b41e2865 use fragment navigation with letter bar (#139) 2021-04-04 20:30:40 -05:00
Jason DoveandGitHub d249e95f12 fix poster width (#138) 2021-04-04 20:13:29 -05:00
168 changed files with 18347 additions and 532 deletions
+4
View File
@@ -79,3 +79,7 @@ indent_size=2
indent_style=space
indent_size=4
tab_width=4
[*.yml]
indent_style = space
indent_size = 2
+2 -1
View File
@@ -1,5 +1,6 @@
name: Build
on:
workflow_dispatch:
pull_request:
push:
branches:
@@ -35,7 +36,7 @@ jobs:
name: Build & Publish to Docker Hub
needs: build_and_test
runs-on: ubuntu-latest
if: github.event_name == 'push' && !contains(github.event.head_commit.message, '[no ci]')
if: github.event_name == 'push' && !contains(github.event.head_commit.message, '[no docker]')
steps:
- name: Checkout
uses: actions/checkout@v2
+19
View File
@@ -0,0 +1,19 @@
name: Publish docs via GitHub Pages
on:
push:
branches:
- main
jobs:
build:
name: Deploy docs
runs-on: ubuntu-latest
steps:
- name: Checkout master
uses: actions/checkout@v1
- name: Deploy docs
uses: mhausenblas/mkdocs-deploy-gh-pages@master
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
CUSTOM_DOMAIN: ersatztv.org
@@ -0,0 +1,14 @@
using System.Collections.Generic;
namespace ErsatzTV.Application.Artists
{
public record ArtistViewModel(
string Name,
string Disambiguation,
string Biography,
string Thumbnail,
string FanArt,
List<string> Genres,
List<string> Styles,
List<string> Moods);
}
+27
View File
@@ -0,0 +1,27 @@
using System.Linq;
using ErsatzTV.Core.Domain;
using static LanguageExt.Prelude;
namespace ErsatzTV.Application.Artists
{
internal static class Mapper
{
internal static ArtistViewModel ProjectToViewModel(Artist artist)
{
ArtistMetadata metadata = Optional(artist.ArtistMetadata).Flatten().Head();
return new ArtistViewModel(
metadata.Title,
metadata.Disambiguation,
metadata.Biography,
Artwork(metadata, ArtworkKind.Thumbnail),
Artwork(metadata, ArtworkKind.FanArt),
metadata.Genres.Map(g => g.Name).ToList(),
metadata.Styles.Map(s => s.Name).ToList(),
metadata.Moods.Map(m => m.Name).ToList());
}
private static string Artwork(Metadata metadata, ArtworkKind artworkKind) =>
Optional(metadata.Artwork.FirstOrDefault(a => a.ArtworkKind == artworkKind))
.Match(a => a.Path, string.Empty);
}
}
@@ -0,0 +1,7 @@
using LanguageExt;
using MediatR;
namespace ErsatzTV.Application.Artists.Queries
{
public record GetArtistById(int ArtistId) : IRequest<Option<ArtistViewModel>>;
}
@@ -0,0 +1,21 @@
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Core.Interfaces.Repositories;
using LanguageExt;
using MediatR;
using static ErsatzTV.Application.Artists.Mapper;
namespace ErsatzTV.Application.Artists.Queries
{
public class GetArtistByIdHandler : IRequestHandler<GetArtistById, Option<ArtistViewModel>>
{
private readonly IArtistRepository _artistRepository;
public GetArtistByIdHandler(IArtistRepository artistRepository) => _artistRepository = artistRepository;
public Task<Option<ArtistViewModel>> Handle(
GetArtistById request,
CancellationToken cancellationToken) =>
_artistRepository.GetArtist(request.ArtistId).MapT(ProjectToViewModel);
}
}
@@ -0,0 +1,7 @@
using ErsatzTV.Core;
using LanguageExt;
namespace ErsatzTV.Application.HDHR.Commands
{
public record UpdateHDHRTunerCount(int TunerCount) : MediatR.IRequest<Either<BaseError, Unit>>;
}
@@ -0,0 +1,45 @@
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using LanguageExt;
using static LanguageExt.Prelude;
namespace ErsatzTV.Application.HDHR.Commands
{
public class UpdateHDHRTunerCountHandler : MediatR.IRequestHandler<UpdateHDHRTunerCount, Either<BaseError, Unit>>
{
private readonly IConfigElementRepository _configElementRepository;
public UpdateHDHRTunerCountHandler(IConfigElementRepository configElementRepository) =>
_configElementRepository = configElementRepository;
public Task<Either<BaseError, Unit>> Handle(
UpdateHDHRTunerCount request,
CancellationToken cancellationToken) =>
Validate(request)
.MapT(_ => Upsert(ConfigElementKey.HDHRTunerCount, request.TunerCount.ToString()))
.Bind(v => v.ToEitherAsync());
private Task<Validation<BaseError, Unit>> Validate(UpdateHDHRTunerCount request) =>
Optional(request.TunerCount)
.Filter(tc => tc > 0)
.Map(_ => Unit.Default)
.ToValidation<BaseError>("Tuner count must be greater than zero")
.AsTask();
private Task<Unit> Upsert(ConfigElementKey key, string value) =>
_configElementRepository.Get(key).Match(
ce =>
{
ce.Value = value;
return _configElementRepository.Update(ce);
},
() =>
{
var ce = new ConfigElement { Key = key.Key, Value = value };
return _configElementRepository.Add(ce);
}).ToUnit();
}
}
@@ -0,0 +1,6 @@
using MediatR;
namespace ErsatzTV.Application.HDHR.Queries
{
public record GetHDHRTunerCount : IRequest<int>;
}
@@ -0,0 +1,21 @@
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using LanguageExt;
using MediatR;
namespace ErsatzTV.Application.HDHR.Queries
{
public class GetHDHRTunerCountHandler : IRequestHandler<GetHDHRTunerCount, int>
{
private readonly IConfigElementRepository _configElementRepository;
public GetHDHRTunerCountHandler(IConfigElementRepository configElementRepository) =>
_configElementRepository = configElementRepository;
public Task<int> Handle(GetHDHRTunerCount request, CancellationToken cancellationToken) =>
_configElementRepository.GetValue<int>(ConfigElementKey.HDHRTunerCount)
.Map(result => result.IfNone(2));
}
}
@@ -1,12 +1,12 @@
using System;
using Serilog.Events;
namespace ErsatzTV.Application.Logs
{
public record LogEntryViewModel(
int Id,
DateTime Timestamp,
string Level,
LogEventLevel Level,
string Exception,
string RenderedMessage,
string Properties);
string Message);
}
+35 -6
View File
@@ -1,16 +1,45 @@
using ErsatzTV.Core.Domain;
using System;
using System.Collections.Generic;
using ErsatzTV.Core.Domain;
using Newtonsoft.Json.Linq;
using Serilog.Events;
namespace ErsatzTV.Application.Logs
{
internal static class Mapper
{
internal static LogEntryViewModel ProjectToViewModel(LogEntry logEntry) =>
new(
internal static LogEntryViewModel ProjectToViewModel(LogEntry logEntry)
{
string message = logEntry.RenderedMessage;
if (!string.IsNullOrWhiteSpace(logEntry.Properties))
{
foreach (KeyValuePair<string, JToken> property in JObject.Parse(logEntry.Properties))
{
var token = $"{{{property.Key}}}";
if (message.Contains(token))
{
message = message.Replace(token, property.Value.ToString());
}
var destructureToken = $"{{@{property.Key}}}";
if (message.Contains(destructureToken))
{
message = message.Replace(destructureToken, property.Value.ToString());
}
}
}
if (!Enum.TryParse(logEntry.Level, out LogEventLevel level))
{
level = LogEventLevel.Debug;
}
return new LogEntryViewModel(
logEntry.Id,
logEntry.Timestamp,
logEntry.Level,
level,
logEntry.Exception,
logEntry.RenderedMessage,
logEntry.Properties);
message);
}
}
}
@@ -0,0 +1,11 @@
using System.Collections.Generic;
using ErsatzTV.Core.Search;
using LanguageExt;
namespace ErsatzTV.Application.MediaCards
{
public record ArtistCardResultsViewModel(
int Count,
List<ArtistCardViewModel> Cards,
Option<SearchPageMap> PageMap);
}
@@ -0,0 +1,12 @@
namespace ErsatzTV.Application.MediaCards
{
public record ArtistCardViewModel
(int ArtistId, string Title, string Subtitle, string SortTitle, string Poster) : MediaCardViewModel(
ArtistId,
Title,
Subtitle,
SortTitle,
Poster)
{
}
}
@@ -8,6 +8,7 @@ namespace ErsatzTV.Application.MediaCards
List<TelevisionShowCardViewModel> ShowCards,
List<TelevisionSeasonCardViewModel> SeasonCards,
List<TelevisionEpisodeCardViewModel> EpisodeCards,
List<ArtistCardViewModel> ArtistCards,
List<MusicVideoCardViewModel> MusicVideoCards)
{
public bool UseCustomPlaybackOrder { get; set; }
+12 -2
View File
@@ -55,11 +55,20 @@ namespace ErsatzTV.Application.MediaCards
internal static MusicVideoCardViewModel ProjectToViewModel(MusicVideoMetadata musicVideoMetadata) =>
new(
musicVideoMetadata.MusicVideoId,
$"{musicVideoMetadata.Title} ({musicVideoMetadata.Artist})",
musicVideoMetadata.Year?.ToString(),
musicVideoMetadata.Title,
musicVideoMetadata.MusicVideo.Artist.ArtistMetadata.Head().Title,
musicVideoMetadata.SortTitle,
musicVideoMetadata.Plot,
GetThumbnail(musicVideoMetadata));
internal static ArtistCardViewModel ProjectToViewModel(ArtistMetadata artistMetadata) =>
new(
artistMetadata.ArtistId,
artistMetadata.Title,
artistMetadata.Disambiguation,
artistMetadata.SortTitle,
GetThumbnail(artistMetadata));
internal static CollectionCardResultsViewModel
ProjectToViewModel(Collection collection) =>
new(
@@ -73,6 +82,7 @@ namespace ErsatzTV.Application.MediaCards
collection.MediaItems.OfType<Season>().Map(ProjectToViewModel).ToList(),
collection.MediaItems.OfType<Episode>().Map(e => ProjectToViewModel(e.EpisodeMetadata.Head()))
.ToList(),
collection.MediaItems.OfType<Artist>().Map(a => ProjectToViewModel(a.ArtistMetadata.Head())).ToList(),
collection.MediaItems.OfType<MusicVideo>().Map(mv => ProjectToViewModel(mv.MusicVideoMetadata.Head()))
.ToList()) { UseCustomPlaybackOrder = collection.UseCustomPlaybackOrder };
@@ -1,7 +1,7 @@
namespace ErsatzTV.Application.MediaCards
{
public record MusicVideoCardViewModel
(int MusicVideoId, string Title, string Subtitle, string SortTitle, string Poster) : MediaCardViewModel(
(int MusicVideoId, string Title, string Subtitle, string SortTitle, string Plot, string Poster) : MediaCardViewModel(
MusicVideoId,
Title,
Subtitle,
@@ -0,0 +1,7 @@
using MediatR;
namespace ErsatzTV.Application.MediaCards.Queries
{
public record GetMusicVideoCards
(int ArtistId, int PageNumber, int PageSize) : IRequest<MusicVideoCardResultsViewModel>;
}
@@ -0,0 +1,33 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Core.Interfaces.Repositories;
using LanguageExt;
using MediatR;
using static ErsatzTV.Application.MediaCards.Mapper;
using static LanguageExt.Prelude;
namespace ErsatzTV.Application.MediaCards.Queries
{
public class GetMusicVideoCardsHandler : IRequestHandler<GetMusicVideoCards, MusicVideoCardResultsViewModel>
{
private readonly IMusicVideoRepository _musicVideoRepository;
public GetMusicVideoCardsHandler(IMusicVideoRepository musicVideoRepository) =>
_musicVideoRepository = musicVideoRepository;
public async Task<MusicVideoCardResultsViewModel> Handle(
GetMusicVideoCards request,
CancellationToken cancellationToken)
{
int count = await _musicVideoRepository.GetMusicVideoCount(request.ArtistId);
List<MusicVideoCardViewModel> results = await _musicVideoRepository
.GetPagedMusicVideos(request.ArtistId, request.PageNumber, request.PageSize)
.Map(list => list.Map(ProjectToViewModel).ToList());
return new MusicVideoCardResultsViewModel(count, results, None);
}
}
}
@@ -0,0 +1,8 @@
using ErsatzTV.Core;
using LanguageExt;
namespace ErsatzTV.Application.MediaCollections.Commands
{
public record AddArtistToCollection
(int CollectionId, int ArtistId) : MediatR.IRequest<Either<BaseError, Unit>>;
}
@@ -0,0 +1,68 @@
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 LanguageExt;
namespace ErsatzTV.Application.MediaCollections.Commands
{
public class
AddArtistToCollectionHandler : MediatR.IRequestHandler<AddArtistToCollection, Either<BaseError, Unit>>
{
private readonly ChannelWriter<IBackgroundServiceRequest> _channel;
private readonly IMediaCollectionRepository _mediaCollectionRepository;
private readonly IArtistRepository _artistRepository;
public AddArtistToCollectionHandler(
IMediaCollectionRepository mediaCollectionRepository,
IArtistRepository artistRepository,
ChannelWriter<IBackgroundServiceRequest> channel)
{
_mediaCollectionRepository = mediaCollectionRepository;
_artistRepository = artistRepository;
_channel = channel;
}
public Task<Either<BaseError, Unit>> Handle(
AddArtistToCollection request,
CancellationToken cancellationToken) =>
Validate(request)
.MapT(_ => ApplyAddArtistRequest(request))
.Bind(v => v.ToEitherAsync());
private async Task<Unit> ApplyAddArtistRequest(AddArtistToCollection request)
{
if (await _mediaCollectionRepository.AddMediaItem(request.CollectionId, request.ArtistId))
{
// rebuild all playouts that use this collection
foreach (int playoutId in await _mediaCollectionRepository
.PlayoutIdsUsingCollection(request.CollectionId))
{
await _channel.WriteAsync(new BuildPlayout(playoutId, true));
}
}
return Unit.Default;
}
private async Task<Validation<BaseError, Unit>> Validate(AddArtistToCollection request) =>
(await CollectionMustExist(request), await ValidateArtist(request))
.Apply((_, _) => Unit.Default);
private Task<Validation<BaseError, Unit>> CollectionMustExist(AddArtistToCollection request) =>
_mediaCollectionRepository.GetCollectionWithItems(request.CollectionId)
.MapT(_ => Unit.Default)
.Map(v => v.ToValidation<BaseError>("Collection does not exist."));
private Task<Validation<BaseError, Unit>> ValidateArtist(AddArtistToCollection request) =>
LoadArtist(request)
.MapT(_ => Unit.Default)
.Map(v => v.ToValidation<BaseError>("Music video does not exist"));
private Task<Option<Artist>> LoadArtist(AddArtistToCollection request) =>
_artistRepository.GetArtist(request.ArtistId);
}
}
@@ -9,5 +9,6 @@ namespace ErsatzTV.Application.MediaCollections.Commands
int CollectionId,
List<int> MovieIds,
List<int> ShowIds,
List<int> ArtistIds,
List<int> MusicVideoIds) : MediatR.IRequest<Either<BaseError, Unit>>;
}
@@ -39,9 +39,13 @@ namespace ErsatzTV.Application.MediaCollections.Commands
private async Task<Unit> ApplyAddItemsRequest(AddItemsToCollection request)
{
if (await _mediaCollectionRepository.AddMediaItems(
request.CollectionId,
request.MovieIds.Append(request.ShowIds).Append(request.MusicVideoIds).ToList()))
var allItems = request.MovieIds
.Append(request.ShowIds)
.Append(request.ArtistIds)
.Append(request.MusicVideoIds)
.ToList();
if (await _mediaCollectionRepository.AddMediaItems(request.CollectionId, allItems))
{
// rebuild all playouts that use this collection
foreach (int playoutId in await _mediaCollectionRepository
@@ -0,0 +1,6 @@
using System.Collections.Generic;
namespace ErsatzTV.Application.MediaCollections
{
public record PagedMediaCollectionsViewModel(int TotalCount, List<MediaCollectionViewModel> Page);
}
@@ -0,0 +1,6 @@
using MediatR;
namespace ErsatzTV.Application.MediaCollections.Queries
{
public record GetPagedCollections(int PageNum, int PageSize) : IRequest<PagedMediaCollectionsViewModel>;
}
@@ -0,0 +1,32 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Core.Interfaces.Repositories;
using LanguageExt;
using MediatR;
using static ErsatzTV.Application.MediaCollections.Mapper;
namespace ErsatzTV.Application.MediaCollections.Queries
{
public class GetPagedCollectionsHandler : IRequestHandler<GetPagedCollections, PagedMediaCollectionsViewModel>
{
private readonly IMediaCollectionRepository _mediaCollectionRepository;
public GetPagedCollectionsHandler(IMediaCollectionRepository mediaCollectionRepository) =>
_mediaCollectionRepository = mediaCollectionRepository;
public async Task<PagedMediaCollectionsViewModel> Handle(
GetPagedCollections request,
CancellationToken cancellationToken)
{
int count = await _mediaCollectionRepository.CountAllCollections();
List<MediaCollectionViewModel> page = await _mediaCollectionRepository
.GetPagedCollections(request.PageNum, request.PageSize)
.Map(list => list.Map(ProjectToViewModel).ToList());
return new PagedMediaCollectionsViewModel(count, page);
}
}
}
+4 -1
View File
@@ -37,7 +37,10 @@ namespace ErsatzTV.Application.Playouts
case Movie m:
return m.MovieMetadata.HeadOrNone().Map(mm => mm.Title).IfNone("[unknown movie]");
case MusicVideo mv:
return mv.MusicVideoMetadata.HeadOrNone().Map(mvm => $"{mvm.Artist} - {mvm.Title}")
string artistName = mv.Artist.ArtistMetadata.HeadOrNone()
.Map(am => $"{am.Title} - ").IfNone(string.Empty);
return mv.MusicVideoMetadata.HeadOrNone()
.Map(mvm => $"{artistName}{mvm.Title}")
.IfNone("[unknown music video]");
default:
return string.Empty;
@@ -10,6 +10,7 @@ using ErsatzTV.Core.Interfaces.Plex;
using ErsatzTV.Core.Interfaces.Repositories;
using LanguageExt;
using MediatR;
using Microsoft.Extensions.Logging;
namespace ErsatzTV.Application.Plex.Commands
{
@@ -19,6 +20,7 @@ namespace ErsatzTV.Application.Plex.Commands
{
private readonly ChannelWriter<IPlexBackgroundServiceRequest> _channel;
private readonly IEntityLocker _entityLocker;
private readonly ILogger<SynchronizePlexMediaSourcesHandler> _logger;
private readonly IMediaSourceRepository _mediaSourceRepository;
private readonly IPlexTvApiClient _plexTvApiClient;
@@ -26,12 +28,14 @@ namespace ErsatzTV.Application.Plex.Commands
IMediaSourceRepository mediaSourceRepository,
IPlexTvApiClient plexTvApiClient,
ChannelWriter<IPlexBackgroundServiceRequest> channel,
IEntityLocker entityLocker)
IEntityLocker entityLocker,
ILogger<SynchronizePlexMediaSourcesHandler> logger)
{
_mediaSourceRepository = mediaSourceRepository;
_plexTvApiClient = plexTvApiClient;
_channel = channel;
_entityLocker = entityLocker;
_logger = logger;
}
public Task<Either<BaseError, List<PlexMediaSource>>> Handle(
@@ -47,6 +51,14 @@ namespace ErsatzTV.Application.Plex.Commands
await SynchronizeServer(allExisting, server);
}
// delete removed servers
foreach (PlexMediaSource removed in allExisting.Filter(
s => servers.All(pms => pms.ClientIdentifier != s.ClientIdentifier)))
{
_logger.LogWarning("Deleting removed Plex server {ServerName}!", removed.Id.ToString());
await _mediaSourceRepository.DeletePlex(removed);
}
foreach (PlexMediaSource mediaSource in await _mediaSourceRepository.GetAllPlex())
{
await _channel.WriteAsync(new SynchronizePlexLibraries(mediaSource.Id));
@@ -72,7 +84,7 @@ namespace ErsatzTV.Application.Plex.Commands
.Filter(connection => existing.Connections.All(c => c.Uri != connection.Uri)).ToList();
var toRemove = existing.Connections
.Filter(connection => server.Connections.All(c => c.Uri != connection.Uri)).ToList();
return _mediaSourceRepository.Update(existing, toAdd, toRemove);
return _mediaSourceRepository.Update(existing, server.Connections, toAdd, toRemove);
},
async () =>
{
@@ -4,6 +4,7 @@ using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Search;
using LanguageExt;
@@ -14,6 +15,7 @@ namespace ErsatzTV.Application.Search.Commands
public class RebuildSearchIndexHandler : MediatR.IRequestHandler<RebuildSearchIndex, Unit>
{
private readonly IConfigElementRepository _configElementRepository;
private readonly ILocalFileSystem _localFileSystem;
private readonly ILogger<RebuildSearchIndexHandler> _logger;
private readonly ISearchIndex _searchIndex;
private readonly ISearchRepository _searchRepository;
@@ -22,18 +24,22 @@ namespace ErsatzTV.Application.Search.Commands
ISearchIndex searchIndex,
ISearchRepository searchRepository,
IConfigElementRepository configElementRepository,
ILocalFileSystem localFileSystem,
ILogger<RebuildSearchIndexHandler> logger)
{
_searchIndex = searchIndex;
_logger = logger;
_searchRepository = searchRepository;
_configElementRepository = configElementRepository;
_localFileSystem = localFileSystem;
}
public async Task<Unit> Handle(RebuildSearchIndex request, CancellationToken cancellationToken)
{
bool indexFolderExists = Directory.Exists(FileSystemLayout.SearchIndexFolder);
await _searchIndex.Initialize(_localFileSystem);
if (!indexFolderExists ||
await _configElementRepository.GetValue<int>(ConfigElementKey.SearchIndexVersion) <
_searchIndex.Version)
@@ -41,7 +47,7 @@ namespace ErsatzTV.Application.Search.Commands
_logger.LogDebug("Migrating search index to version {Version}", _searchIndex.Version);
List<int> itemIds = await _searchRepository.GetItemIdsToIndex();
await _searchIndex.Rebuild(itemIds);
await _searchIndex.Rebuild(_searchRepository, itemIds);
Option<ConfigElement> maybeVersion =
await _configElementRepository.Get(ConfigElementKey.SearchIndexVersion);
@@ -1,7 +0,0 @@
using ErsatzTV.Core.Search;
using MediatR;
namespace ErsatzTV.Application.Search.Queries
{
public record QuerySearchIndex(string Query) : IRequest<SearchResult>;
}
@@ -0,0 +1,6 @@
using MediatR;
namespace ErsatzTV.Application.Search.Queries
{
public record QuerySearchIndexAllItems(string Query) : IRequest<SearchResultAllItemsViewModel>;
}
@@ -0,0 +1,34 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Core.Interfaces.Search;
using LanguageExt;
using MediatR;
namespace ErsatzTV.Application.Search.Queries
{
public class
QuerySearchIndexAllItemsHandler : IRequestHandler<QuerySearchIndexAllItems, SearchResultAllItemsViewModel>
{
private readonly ISearchIndex _searchIndex;
public QuerySearchIndexAllItemsHandler(ISearchIndex searchIndex) => _searchIndex = searchIndex;
public async Task<SearchResultAllItemsViewModel> Handle(
QuerySearchIndexAllItems request,
CancellationToken cancellationToken)
{
List<int> movieIds = await _searchIndex.Search($"type:movie AND ({request.Query})", 0, 0)
.Map(result => result.Items.Map(i => i.Id).ToList());
List<int> showIds = await _searchIndex.Search($"type:show AND ({request.Query})", 0, 0)
.Map(result => result.Items.Map(i => i.Id).ToList());
List<int> artistIds = await _searchIndex.Search($"type:artist AND ({request.Query})", 0, 0)
.Map(result => result.Items.Map(i => i.Id).ToList());
List<int> musicVideoIds = await _searchIndex.Search($"type:music_video AND ({request.Query})", 0, 0)
.Map(result => result.Items.Map(i => i.Id).ToList());
return new SearchResultAllItemsViewModel(movieIds, showIds, artistIds, musicVideoIds);
}
}
}
@@ -0,0 +1,8 @@
using ErsatzTV.Application.MediaCards;
using MediatR;
namespace ErsatzTV.Application.Search.Queries
{
public record QuerySearchIndexArtists
(string Query, int PageNumber, int PageSize) : IRequest<ArtistCardResultsViewModel>;
}
@@ -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
QuerySearchIndexArtistsHandler : IRequestHandler<QuerySearchIndexArtists, ArtistCardResultsViewModel
>
{
private readonly IArtistRepository _artistRepository;
private readonly ISearchIndex _searchIndex;
public QuerySearchIndexArtistsHandler(ISearchIndex searchIndex, IArtistRepository artistRepository)
{
_searchIndex = searchIndex;
_artistRepository = artistRepository;
}
public async Task<ArtistCardResultsViewModel> Handle(
QuerySearchIndexArtists request,
CancellationToken cancellationToken)
{
SearchResult searchResult = await _searchIndex.Search(
request.Query,
(request.PageNumber - 1) * request.PageSize,
request.PageSize);
List<ArtistCardViewModel> items = await _artistRepository
.GetArtistsForCards(searchResult.Items.Map(i => i.Id).ToList())
.Map(list => list.Map(ProjectToViewModel).ToList());
return new ArtistCardResultsViewModel(searchResult.TotalCount, items, searchResult.PageMap);
}
}
}
@@ -1,18 +0,0 @@
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Core.Search;
using MediatR;
namespace ErsatzTV.Application.Search.Queries
{
public class QuerySearchIndexHandler : IRequestHandler<QuerySearchIndex, SearchResult>
{
private readonly ISearchIndex _searchIndex;
public QuerySearchIndexHandler(ISearchIndex searchIndex) => _searchIndex = searchIndex;
public Task<SearchResult> Handle(QuerySearchIndex request, CancellationToken cancellationToken) =>
_searchIndex.Search(request.Query, 0, 100);
}
}
@@ -0,0 +1,6 @@
using System.Collections.Generic;
namespace ErsatzTV.Application.Search
{
public record SearchResultAllItemsViewModel(List<int> MovieIds, List<int> ShowIds, List<int> ArtistIds, List<int> MusicVideoIds);
}
@@ -26,6 +26,11 @@ namespace ErsatzTV.Core.Tests.Fakes
throw new NotSupportedException();
public Task<List<Collection>> GetAll() => throw new NotSupportedException();
public Task<int> CountAllCollections() => throw new NotSupportedException();
public Task<List<Collection>> GetPagedCollections(int pageNumber, int pageSize) =>
throw new NotSupportedException();
public Task<Option<List<MediaItem>>> GetItems(int id) => Some(_data[id].ToList()).AsTask();
Task<bool> IMediaCollectionRepository.Update(Collection collection) => throw new NotSupportedException();
public Task Delete(int collectionId) => throw new NotSupportedException();
@@ -557,6 +557,7 @@ namespace ErsatzTV.Core.Tests.Metadata
new Mock<IMetadataRepository>().Object,
_imageCache.Object,
new Mock<ISearchIndex>().Object,
new Mock<ISearchRepository>().Object,
new Mock<IMediator>().Object,
new Mock<ILogger<MovieFolderScanner>>().Object
);
+1
View File
@@ -13,5 +13,6 @@
public static ConfigElementKey FFmpegSaveReports => new("ffmpeg.save_reports");
public static ConfigElementKey FFmpegPreferredLanguageCode => new("ffmpeg.preferred_language_code");
public static ConfigElementKey SearchIndexVersion => new("search_index.version");
public static ConfigElementKey HDHRTunerCount => new("hdhr.tuner_count");
}
}
+10
View File
@@ -0,0 +1,10 @@
using System.Collections.Generic;
namespace ErsatzTV.Core.Domain
{
public class Artist : MediaItem
{
public List<MusicVideo> MusicVideos { get; set; }
public List<ArtistMetadata> ArtistMetadata { get; set; }
}
}
@@ -4,6 +4,8 @@ namespace ErsatzTV.Core.Domain
{
public class MusicVideo : MediaItem
{
public int ArtistId { get; set; }
public Artist Artist { get; set; }
public List<MusicVideoMetadata> MusicVideoMetadata { get; set; }
public List<MediaVersion> MediaVersions { get; set; }
}
@@ -0,0 +1,15 @@
using System.Collections.Generic;
namespace ErsatzTV.Core.Domain
{
public class ArtistMetadata : Metadata
{
public string Disambiguation { get; set; }
public string Biography { get; set; }
public string Formed { get; set; }
public int ArtistId { get; set; }
public Artist Artist { get; set; }
public List<Style> Styles { get; set; }
public List<Mood> Moods { get; set; }
}
}
+8
View File
@@ -0,0 +1,8 @@
namespace ErsatzTV.Core.Domain
{
public class Mood
{
public int Id { get; set; }
public string Name { get; set; }
}
}
@@ -4,7 +4,6 @@
{
public string Album { get; set; }
public string Plot { get; set; }
public string Artist { get; set; }
public int MusicVideoId { get; set; }
public MusicVideo MusicVideo { get; set; }
}
+8
View File
@@ -0,0 +1,8 @@
namespace ErsatzTV.Core.Domain
{
public class Style
{
public int Id { get; set; }
public string Name { get; set; }
}
}
@@ -68,12 +68,12 @@ namespace ErsatzTV.Core.FFmpeg
return this;
}
public Option<FFmpegComplexFilter> Build(int videoStreamIndex, int audioStreamIndex)
public Option<FFmpegComplexFilter> Build(int videoStreamIndex, Option<int> audioStreamIndex)
{
var complexFilter = new StringBuilder();
var videoLabel = $"0:{videoStreamIndex}";
var audioLabel = $"0:{audioStreamIndex}";
string audioLabel = audioStreamIndex.Match(index => $"0:{index}", () => "0:a");
HardwareAccelerationKind acceleration = _hardwareAccelerationKind.IfNone(HardwareAccelerationKind.None);
bool isHardwareDecode = acceleration switch
@@ -22,6 +22,7 @@ using System;
using System.Collections.Generic;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.FFmpeg;
using LanguageExt;
using static LanguageExt.Prelude;
namespace ErsatzTV.Core.FFmpeg
@@ -46,7 +47,7 @@ namespace ErsatzTV.Core.FFmpeg
FFmpegProfile ffmpegProfile,
MediaVersion version,
MediaStream videoStream,
MediaStream audioStream,
Option<MediaStream> audioStream,
DateTimeOffset start,
DateTimeOffset now)
{
@@ -113,10 +114,14 @@ namespace ErsatzTV.Core.FFmpeg
result.AudioBitrate = ffmpegProfile.AudioBitrate;
result.AudioBufferSize = ffmpegProfile.AudioBufferSize;
if (audioStream.Channels != ffmpegProfile.AudioChannels)
{
result.AudioChannels = ffmpegProfile.AudioChannels;
}
audioStream.IfSome(
stream =>
{
if (stream.Channels != ffmpegProfile.AudioChannels)
{
result.AudioChannels = ffmpegProfile.AudioChannels;
}
});
result.AudioSampleRate = ffmpegProfile.AudioSampleRate;
result.AudioDuration = version.Duration;
+7 -4
View File
@@ -354,12 +354,15 @@ namespace ErsatzTV.Core.FFmpeg
return this;
}
public FFmpegProcessBuilder WithFilterComplex(int videoStreamIndex, int audioStreamIndex)
public FFmpegProcessBuilder WithFilterComplex(MediaStream videoStream, Option<MediaStream> maybeAudioStream)
{
var videoLabel = $"0:{videoStreamIndex}";
var audioLabel = $"0:{audioStreamIndex}";
int videoStreamIndex = videoStream.Index;
Option<int> maybeIndex = maybeAudioStream.Map(ms => ms.Index);
Option<FFmpegComplexFilter> maybeFilter = _complexFilterBuilder.Build(videoStreamIndex, audioStreamIndex);
var videoLabel = $"0:{videoStreamIndex}";
var audioLabel = $"0:{maybeIndex.Match(i => i.ToString(), () => "a")}";
Option<FFmpegComplexFilter> maybeFilter = _complexFilterBuilder.Build(videoStreamIndex, maybeIndex);
maybeFilter.IfSome(
filter =>
{
+6 -6
View File
@@ -30,14 +30,14 @@ namespace ErsatzTV.Core.FFmpeg
DateTimeOffset now)
{
MediaStream videoStream = await _ffmpegStreamSelector.SelectVideoStream(channel, version);
MediaStream audioStream = await _ffmpegStreamSelector.SelectAudioStream(channel, version);
Option<MediaStream> maybeAudioStream = await _ffmpegStreamSelector.SelectAudioStream(channel, version);
FFmpegPlaybackSettings playbackSettings = _playbackSettingsCalculator.CalculateSettings(
channel.StreamingMode,
channel.FFmpegProfile,
version,
videoStream,
audioStream,
maybeAudioStream,
start,
now);
@@ -67,7 +67,7 @@ namespace ErsatzTV.Core.FFmpeg
}
builder = builder
.WithFilterComplex(videoStream.Index, audioStream.Index);
.WithFilterComplex(videoStream, maybeAudioStream);
},
() =>
{
@@ -76,18 +76,18 @@ namespace ErsatzTV.Core.FFmpeg
builder = builder
.WithDeinterlace(playbackSettings.Deinterlace)
.WithBlackBars(channel.FFmpegProfile.Resolution)
.WithFilterComplex(videoStream.Index, audioStream.Index);
.WithFilterComplex(videoStream, maybeAudioStream);
}
else if (playbackSettings.Deinterlace)
{
builder = builder.WithDeinterlace(playbackSettings.Deinterlace)
.WithAlignedAudio(playbackSettings.AudioDuration)
.WithFilterComplex(videoStream.Index, audioStream.Index);
.WithFilterComplex(videoStream, maybeAudioStream);
}
else
{
builder = builder
.WithFilterComplex(videoStream.Index, audioStream.Index);
.WithFilterComplex(videoStream, maybeAudioStream);
}
});
+11 -1
View File
@@ -6,6 +6,7 @@ using ErsatzTV.Core.Interfaces.FFmpeg;
using ErsatzTV.Core.Interfaces.Repositories;
using LanguageExt;
using Microsoft.Extensions.Logging;
using static LanguageExt.Prelude;
namespace ErsatzTV.Core.FFmpeg
{
@@ -25,8 +26,17 @@ namespace ErsatzTV.Core.FFmpeg
public Task<MediaStream> SelectVideoStream(Channel channel, MediaVersion version) =>
version.Streams.First(s => s.MediaStreamKind == MediaStreamKind.Video).AsTask();
public async Task<MediaStream> SelectAudioStream(Channel channel, MediaVersion version)
public async Task<Option<MediaStream>> SelectAudioStream(Channel channel, MediaVersion version)
{
if (channel.StreamingMode == StreamingMode.HttpLiveStreaming &&
string.IsNullOrWhiteSpace(channel.PreferredLanguageCode))
{
_logger.LogDebug(
"Channel {Number} is HLS with no preferred language; using all audio streams",
channel.Number);
return None;
}
var audioStreams = version.Streams.Filter(s => s.MediaStreamKind == MediaStreamKind.Audio).ToList();
string language = (channel.PreferredLanguageCode ?? string.Empty).ToLowerInvariant();
@@ -1,11 +1,12 @@
using System.Threading.Tasks;
using ErsatzTV.Core.Domain;
using LanguageExt;
namespace ErsatzTV.Core.Interfaces.FFmpeg
{
public interface IFFmpegStreamSelector
{
Task<MediaStream> SelectVideoStream(Channel channel, MediaVersion version);
Task<MediaStream> SelectAudioStream(Channel channel, MediaVersion version);
Task<Option<MediaStream>> SelectAudioStream(Channel channel, MediaVersion version);
}
}
@@ -1,14 +1,16 @@
using System;
using ErsatzTV.Core.Domain;
using LanguageExt;
namespace ErsatzTV.Core.Interfaces.Metadata
{
public interface IFallbackMetadataProvider
{
ShowMetadata GetFallbackMetadataForShow(string showFolder);
ArtistMetadata GetFallbackMetadataForArtist(string artistFolder);
Tuple<EpisodeMetadata, int> GetFallbackMetadata(Episode episode);
MovieMetadata GetFallbackMetadata(Movie movie);
MusicVideoMetadata GetFallbackMetadata(MusicVideo musicVideo);
Option<MusicVideoMetadata> GetFallbackMetadata(MusicVideo musicVideo);
string GetSortTitle(string title);
}
}
@@ -6,12 +6,15 @@ namespace ErsatzTV.Core.Interfaces.Metadata
public interface ILocalMetadataProvider
{
Task<ShowMetadata> GetMetadataForShow(string showFolder);
Task<ArtistMetadata> GetMetadataForArtist(string artistFolder);
Task<bool> RefreshSidecarMetadata(Movie movie, string nfoFileName);
Task<bool> RefreshSidecarMetadata(Show televisionShow, string nfoFileName);
Task<bool> RefreshSidecarMetadata(Episode episode, string nfoFileName);
Task<bool> RefreshSidecarMetadata(Artist artist, string nfoFileName);
Task<bool> RefreshSidecarMetadata(MusicVideo musicVideo, string nfoFileName);
Task<bool> RefreshFallbackMetadata(Movie movie);
Task<bool> RefreshFallbackMetadata(Episode episode);
Task<bool> RefreshFallbackMetadata(Artist artist, string artistFolder);
Task<bool> RefreshFallbackMetadata(MusicVideo musicVideo);
Task<bool> RefreshFallbackMetadata(Show televisionShow, string showFolder);
}
@@ -0,0 +1,25 @@
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 IArtistRepository
{
Task<Option<Artist>> GetArtistByMetadata(int libraryPathId, ArtistMetadata metadata);
Task<Either<BaseError, MediaItemScanResult<Artist>>> AddArtist(
int libraryPathId,
string artistFolder,
ArtistMetadata metadata);
Task<List<int>> DeleteEmptyArtists(LibraryPath libraryPath);
Task<Option<Artist>> GetArtist(int artistId);
Task<List<ArtistMetadata>> GetArtistsForCards(List<int> ids);
Task<bool> AddGenre(ArtistMetadata metadata, Genre genre);
Task<bool> AddStyle(ArtistMetadata metadata, Style style);
Task<bool> AddMood(ArtistMetadata metadata, Mood mood);
}
}
@@ -15,6 +15,8 @@ namespace ErsatzTV.Core.Interfaces.Repositories
Task<Option<Collection>> GetCollectionWithItemsUntracked(int id);
Task<Option<Collection>> GetCollectionWithCollectionItemsUntracked(int id);
Task<List<Collection>> GetAll();
Task<int> CountAllCollections();
Task<List<Collection>> GetPagedCollections(int pageNumber, int pageSize);
Task<Option<List<MediaItem>>> GetItems(int id);
Task<bool> Update(Collection collection);
Task Delete(int collectionId);
@@ -20,7 +20,12 @@ namespace ErsatzTV.Core.Interfaces.Repositories
Task<List<PlexPathReplacement>> GetPlexPathReplacementsByLibraryId(int plexLibraryPathId);
Task<int> CountMediaItems(int id);
Task Update(LocalMediaSource localMediaSource);
Task Update(PlexMediaSource plexMediaSource, List<PlexConnection> toAdd, List<PlexConnection> toDelete);
Task Update(
PlexMediaSource plexMediaSource,
List<PlexConnection> prioritizedConnections,
List<PlexConnection> toAdd,
List<PlexConnection> toDelete);
Task<Unit> UpdateLibraries(
int plexMediaSourceId,
@@ -36,6 +41,7 @@ namespace ErsatzTV.Core.Interfaces.Repositories
Task Update(PlexLibrary plexMediaSourceLibrary);
Task Delete(int mediaSourceId);
Task<List<int>> DeleteAllPlex();
Task<List<int>> DeletePlex(PlexMediaSource plexMediaSource);
Task<List<int>> DisablePlexLibrarySync(List<int> libraryIds);
Task EnablePlexLibrarySync(IEnumerable<int> libraryIds);
}
@@ -10,6 +10,8 @@ namespace ErsatzTV.Core.Interfaces.Repositories
Task<bool> RemoveGenre(Genre genre);
Task<bool> RemoveTag(Tag tag);
Task<bool> RemoveStudio(Studio studio);
Task<bool> RemoveStyle(Style style);
Task<bool> RemoveMood(Mood mood);
Task<bool> Update(Domain.Metadata metadata);
Task<bool> Add(Domain.Metadata metadata);
Task<bool> UpdateLocalStatistics(int mediaVersionId, MediaVersion incoming, bool updateVersion = true);
@@ -8,7 +8,11 @@ namespace ErsatzTV.Core.Interfaces.Repositories
{
public interface IMusicVideoRepository
{
Task<Either<BaseError, MediaItemScanResult<MusicVideo>>> GetOrAdd(LibraryPath libraryPath, string path);
Task<Either<BaseError, MediaItemScanResult<MusicVideo>>> GetOrAdd(
Artist artist,
LibraryPath libraryPath,
string path);
Task<IEnumerable<string>> FindMusicVideoPaths(LibraryPath libraryPath);
Task<List<int>> DeleteByPath(LibraryPath libraryPath, string path);
Task<bool> AddGenre(MusicVideoMetadata metadata, Genre genre);
@@ -16,5 +20,8 @@ namespace ErsatzTV.Core.Interfaces.Repositories
Task<bool> AddStudio(MusicVideoMetadata metadata, Studio studio);
Task<List<MusicVideoMetadata>> GetMusicVideosForCards(List<int> ids);
Task<Option<MusicVideo>> GetMusicVideo(int musicVideoId);
Task<IEnumerable<string>> FindOrphanPaths(LibraryPath libraryPath);
Task<int> GetMusicVideoCount(int artistId);
Task<List<MusicVideoMetadata>> GetPagedMusicVideos(int artistId, int pageNumber, int pageSize);
}
}
@@ -9,8 +9,7 @@ namespace ErsatzTV.Core.Interfaces.Repositories
{
public Task<List<int>> GetItemIdsToIndex();
public Task<Option<MediaItem>> GetItemToIndex(int id);
public Task<List<MediaItem>> SearchMediaItemsByTitle(string query);
public Task<List<MediaItem>> SearchMediaItemsByGenre(string genre);
public Task<List<MediaItem>> SearchMediaItemsByTag(string tag);
public Task<List<string>> GetLanguagesForShow(Show show);
public Task<List<string>> GetLanguagesForArtist(Artist artist);
}
}
@@ -1,19 +1,23 @@
using System.Collections.Generic;
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Search;
using LanguageExt;
namespace ErsatzTV.Core.Interfaces.Search
{
public interface ISearchIndex
public interface ISearchIndex : IDisposable
{
public int Version { get; }
Task<bool> Initialize();
Task<Unit> Rebuild(List<int> itemIds);
Task<Unit> AddItems(List<MediaItem> items);
Task<Unit> UpdateItems(List<MediaItem> items);
Task<bool> Initialize(ILocalFileSystem localFileSystem);
Task<Unit> Rebuild(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);
Task<SearchResult> Search(string query, int skip, int limit, string searchField = "");
void Commit();
}
}
+2 -2
View File
@@ -221,8 +221,8 @@ namespace ErsatzTV.Core.Iptv
.IfNone("[unknown movie]"),
Episode e => e.Season.Show.ShowMetadata.HeadOrNone().Map(em => em.Title ?? string.Empty)
.IfNone("[unknown show]"),
MusicVideo mv => mv.MusicVideoMetadata.HeadOrNone().Map(mvm => $"{mvm.Artist} - {mvm.Title}")
.IfNone("[unknown music video]"),
MusicVideo mv => mv.Artist.ArtistMetadata.HeadOrNone().Map(am => am.Title ?? string.Empty)
.IfNone("[unknown artist]"),
_ => "[unknown]"
};
}
@@ -4,6 +4,7 @@ using System.IO;
using System.Text.RegularExpressions;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Metadata;
using LanguageExt;
using static LanguageExt.Prelude;
namespace ErsatzTV.Core.Metadata
@@ -18,6 +19,13 @@ namespace ErsatzTV.Core.Metadata
return GetTelevisionShowMetadata(fileName, metadata);
}
public ArtistMetadata GetFallbackMetadataForArtist(string artistFolder)
{
string fileName = Path.GetFileName(artistFolder);
return new ArtistMetadata
{ MetadataKind = MetadataKind.Fallback, Title = fileName ?? artistFolder };
}
public Tuple<EpisodeMetadata, int> GetFallbackMetadata(Episode episode)
{
string path = episode.MediaVersions.Head().MediaFiles.Head().Path;
@@ -36,7 +44,7 @@ namespace ErsatzTV.Core.Metadata
return fileName != null ? GetMovieMetadata(fileName, metadata) : metadata;
}
public MusicVideoMetadata GetFallbackMetadata(MusicVideo musicVideo)
public Option<MusicVideoMetadata> GetFallbackMetadata(MusicVideo musicVideo)
{
string path = musicVideo.MediaVersions.Head().MediaFiles.Head().Path;
string fileName = Path.GetFileName(path);
@@ -46,7 +54,7 @@ namespace ErsatzTV.Core.Metadata
Title = fileName ?? path
};
return fileName != null ? GetMusicVideoMetadata(fileName, metadata) : metadata;
return GetMusicVideoMetadata(fileName, metadata);
}
public string GetSortTitle(string title)
@@ -125,28 +133,26 @@ namespace ErsatzTV.Core.Metadata
return metadata;
}
private MusicVideoMetadata GetMusicVideoMetadata(string fileName, MusicVideoMetadata metadata)
private Option<MusicVideoMetadata> GetMusicVideoMetadata(string fileName, MusicVideoMetadata metadata)
{
try
{
const string PATTERN = @"^(.*?) - (.*?).\w+$";
Match match = Regex.Match(fileName, PATTERN);
if (match.Success)
{
metadata.Artist = match.Groups[1].Value;
metadata.Title = match.Groups[2].Value;
metadata.Genres = new List<Genre>();
metadata.Tags = new List<Tag>();
metadata.Studios = new List<Studio>();
metadata.DateUpdated = DateTime.UtcNow;
}
metadata.Title = match.Success
? match.Groups[2].Value.Trim()
: Path.GetFileNameWithoutExtension(fileName);
metadata.Genres = new List<Genre>();
metadata.Tags = new List<Tag>();
metadata.Studios = new List<Studio>();
metadata.DateUpdated = DateTime.UtcNow;
return metadata;
}
catch (Exception)
{
// ignored
return None;
}
return metadata;
}
private ShowMetadata GetTelevisionShowMetadata(string fileName, ShowMetadata metadata)
@@ -166,5 +166,9 @@ namespace ErsatzTV.Core.Metadata
return false;
}
protected bool ShouldIncludeFolder(string folder) =>
!Path.GetFileName(folder).StartsWith('.') &&
!_localFileSystem.FileExists(Path.Combine(folder, ".etvignore"));
}
}
+161 -3
View File
@@ -19,7 +19,9 @@ namespace ErsatzTV.Core.Metadata
private static readonly XmlSerializer MovieSerializer = new(typeof(MovieNfo));
private static readonly XmlSerializer EpisodeSerializer = new(typeof(TvShowEpisodeNfo));
private static readonly XmlSerializer TvShowSerializer = new(typeof(TvShowNfo));
private static readonly XmlSerializer ArtistSerializer = new(typeof(ArtistNfo));
private static readonly XmlSerializer MusicVideoSerializer = new(typeof(MusicVideoNfo));
private readonly IArtistRepository _artistRepository;
private readonly IFallbackMetadataProvider _fallbackMetadataProvider;
private readonly ILocalFileSystem _localFileSystem;
private readonly ILogger<LocalMetadataProvider> _logger;
@@ -33,6 +35,7 @@ namespace ErsatzTV.Core.Metadata
IMetadataRepository metadataRepository,
IMovieRepository movieRepository,
ITelevisionRepository televisionRepository,
IArtistRepository artistRepository,
IMusicVideoRepository musicVideoRepository,
IFallbackMetadataProvider fallbackMetadataProvider,
ILocalFileSystem localFileSystem,
@@ -41,6 +44,7 @@ namespace ErsatzTV.Core.Metadata
_metadataRepository = metadataRepository;
_movieRepository = movieRepository;
_televisionRepository = televisionRepository;
_artistRepository = artistRepository;
_musicVideoRepository = musicVideoRepository;
_fallbackMetadataProvider = fallbackMetadataProvider;
_localFileSystem = localFileSystem;
@@ -70,6 +74,29 @@ namespace ErsatzTV.Core.Metadata
});
}
public async Task<ArtistMetadata> GetMetadataForArtist(string artistFolder)
{
string nfoFileName = Path.Combine(artistFolder, "artist.nfo");
Option<ArtistMetadata> maybeMetadata = None;
if (_localFileSystem.FileExists(nfoFileName))
{
maybeMetadata = await LoadArtistMetadata(nfoFileName);
}
return maybeMetadata.Match(
metadata =>
{
metadata.SortTitle = _fallbackMetadataProvider.GetSortTitle(metadata.Title);
return metadata;
},
() =>
{
ArtistMetadata metadata = _fallbackMetadataProvider.GetFallbackMetadataForArtist(artistFolder);
metadata.SortTitle = _fallbackMetadataProvider.GetSortTitle(metadata.Title);
return metadata;
});
}
public Task<bool> RefreshSidecarMetadata(Movie movie, string nfoFileName) =>
LoadMovieMetadata(movie, nfoFileName).Bind(
maybeMetadata => maybeMetadata.Match(
@@ -88,6 +115,12 @@ namespace ErsatzTV.Core.Metadata
metadata => ApplyMetadataUpdate(episode, metadata),
() => Task.FromResult(false)));
public Task<bool> RefreshSidecarMetadata(Artist artist, string nfoFileName) =>
LoadArtistMetadata(nfoFileName).Bind(
maybeMetadata => maybeMetadata.Match(
metadata => ApplyMetadataUpdate(artist, metadata),
() => Task.FromResult(false)));
public Task<bool> RefreshSidecarMetadata(MusicVideo musicVideo, string nfoFileName) =>
LoadMusicVideoMetadata(nfoFileName).Bind(
maybeMetadata => maybeMetadata.Match(
@@ -100,8 +133,13 @@ namespace ErsatzTV.Core.Metadata
public Task<bool> RefreshFallbackMetadata(Episode episode) =>
ApplyMetadataUpdate(episode, _fallbackMetadataProvider.GetFallbackMetadata(episode));
public Task<bool> RefreshFallbackMetadata(Artist artist, string artistFolder) =>
ApplyMetadataUpdate(artist, _fallbackMetadataProvider.GetFallbackMetadataForArtist(artistFolder));
public Task<bool> RefreshFallbackMetadata(MusicVideo musicVideo) =>
ApplyMetadataUpdate(musicVideo, _fallbackMetadataProvider.GetFallbackMetadata(musicVideo));
_fallbackMetadataProvider.GetFallbackMetadata(musicVideo).Match(
metadata => ApplyMetadataUpdate(musicVideo, metadata),
() => Task.FromResult(false));
public Task<bool> RefreshFallbackMetadata(Show televisionShow, string showFolder) =>
ApplyMetadataUpdate(televisionShow, _fallbackMetadataProvider.GetFallbackMetadataForShow(showFolder));
@@ -118,7 +156,6 @@ namespace ErsatzTV.Core.Metadata
MetadataKind = MetadataKind.Sidecar,
DateAdded = DateTime.UtcNow,
DateUpdated = File.GetLastWriteTimeUtc(nfoFileName),
Artist = nfo.Artist,
Album = nfo.Album,
Title = nfo.Title,
Plot = nfo.Plot,
@@ -269,11 +306,104 @@ namespace ErsatzTV.Core.Metadata
return await _metadataRepository.Add(metadata);
});
private Task<bool> ApplyMetadataUpdate(Artist artist, ArtistMetadata metadata) =>
Optional(artist.ArtistMetadata).Flatten().HeadOrNone().Match(
async existing =>
{
existing.Title = metadata.Title;
existing.Disambiguation = metadata.Disambiguation;
existing.Biography = metadata.Biography;
if (existing.DateAdded == DateTime.MinValue)
{
existing.DateAdded = metadata.DateAdded;
}
existing.DateUpdated = metadata.DateUpdated;
existing.MetadataKind = metadata.MetadataKind;
existing.SortTitle = string.IsNullOrWhiteSpace(metadata.SortTitle)
? _fallbackMetadataProvider.GetSortTitle(metadata.Title)
: metadata.SortTitle;
var updated = false;
foreach (Genre genre in existing.Genres.Filter(g => metadata.Genres.All(g2 => g2.Name != g.Name))
.ToList())
{
existing.Genres.Remove(genre);
if (await _metadataRepository.RemoveGenre(genre))
{
updated = true;
}
}
foreach (Genre genre in metadata.Genres.Filter(g => existing.Genres.All(g2 => g2.Name != g.Name))
.ToList())
{
existing.Genres.Add(genre);
if (await _artistRepository.AddGenre(existing, genre))
{
updated = true;
}
}
foreach (Style style in existing.Styles.Filter(s => metadata.Styles.All(s2 => s2.Name != s.Name))
.ToList())
{
existing.Styles.Remove(style);
if (await _metadataRepository.RemoveStyle(style))
{
updated = true;
}
}
foreach (Style style in metadata.Styles.Filter(s => existing.Styles.All(s2 => s2.Name != s.Name))
.ToList())
{
existing.Styles.Add(style);
if (await _artistRepository.AddStyle(existing, style))
{
updated = true;
}
}
foreach (Mood mood in existing.Moods.Filter(m => metadata.Moods.All(m2 => m2.Name != m.Name))
.ToList())
{
existing.Moods.Remove(mood);
if (await _metadataRepository.RemoveMood(mood))
{
updated = true;
}
}
foreach (Mood mood in metadata.Moods.Filter(s => existing.Moods.All(m2 => m2.Name != s.Name))
.ToList())
{
existing.Moods.Add(mood);
if (await _artistRepository.AddMood(existing, mood))
{
updated = true;
}
}
return await _metadataRepository.Update(existing) || updated;
},
async () =>
{
metadata.SortTitle = string.IsNullOrWhiteSpace(metadata.SortTitle)
? _fallbackMetadataProvider.GetSortTitle(metadata.Title)
: metadata.SortTitle;
metadata.ArtistId = artist.Id;
artist.ArtistMetadata = new List<ArtistMetadata> { metadata };
return await _metadataRepository.Add(metadata);
});
private Task<bool> ApplyMetadataUpdate(MusicVideo musicVideo, MusicVideoMetadata metadata) =>
Optional(musicVideo.MusicVideoMetadata).Flatten().HeadOrNone().Match(
async existing =>
{
existing.Artist = metadata.Artist;
existing.Title = metadata.Title;
existing.Year = metadata.Year;
existing.Plot = metadata.Plot;
@@ -343,6 +473,34 @@ namespace ErsatzTV.Core.Metadata
}
}
private async Task<Option<ArtistMetadata>> LoadArtistMetadata(string nfoFileName)
{
try
{
await using FileStream fileStream = File.Open(nfoFileName, FileMode.Open, FileAccess.Read);
Option<ArtistNfo> maybeNfo = ArtistSerializer.Deserialize(fileStream) as ArtistNfo;
return maybeNfo.Match<Option<ArtistMetadata>>(
nfo => new ArtistMetadata
{
MetadataKind = MetadataKind.Sidecar,
DateAdded = DateTime.UtcNow,
DateUpdated = File.GetLastWriteTimeUtc(nfoFileName),
Title = nfo.Name,
Disambiguation = nfo.Disambiguation,
Biography = nfo.Biography,
Genres = nfo.Genres.Map(g => new Genre { Name = g }).ToList(),
Styles = nfo.Styles.Map(s => new Style { Name = s }).ToList(),
Moods = nfo.Moods.Map(m => new Mood { Name = m }).ToList()
},
None);
}
catch (Exception ex)
{
_logger.LogInformation(ex, "Failed to read artist nfo metadata from {Path}", nfoFileName);
return None;
}
}
private async Task<Option<Tuple<EpisodeMetadata, int>>> LoadEpisodeMetadata(Episode episode, string nfoFileName)
{
try
+6 -2
View File
@@ -26,6 +26,7 @@ namespace ErsatzTV.Core.Metadata
private readonly IMediator _mediator;
private readonly IMovieRepository _movieRepository;
private readonly ISearchIndex _searchIndex;
private readonly ISearchRepository _searchRepository;
public MovieFolderScanner(
ILocalFileSystem localFileSystem,
@@ -35,6 +36,7 @@ namespace ErsatzTV.Core.Metadata
IMetadataRepository metadataRepository,
IImageCache imageCache,
ISearchIndex searchIndex,
ISearchRepository searchRepository,
IMediator mediator,
ILogger<MovieFolderScanner> logger)
: base(localFileSystem, localStatisticsProvider, metadataRepository, imageCache, logger)
@@ -43,6 +45,7 @@ namespace ErsatzTV.Core.Metadata
_movieRepository = movieRepository;
_localMetadataProvider = localMetadataProvider;
_searchIndex = searchIndex;
_searchRepository = searchRepository;
_mediator = mediator;
_logger = logger;
}
@@ -115,11 +118,11 @@ namespace ErsatzTV.Core.Metadata
{
if (result.IsAdded)
{
await _searchIndex.AddItems(new List<MediaItem> { result.Item });
await _searchIndex.AddItems(_searchRepository, new List<MediaItem> { result.Item });
}
else if (result.IsUpdated)
{
await _searchIndex.UpdateItems(new List<MediaItem> { result.Item });
await _searchIndex.UpdateItems(_searchRepository, new List<MediaItem> { result.Item });
}
},
error =>
@@ -140,6 +143,7 @@ namespace ErsatzTV.Core.Metadata
}
}
_searchIndex.Commit();
return Unit.Default;
}
+191 -23
View File
@@ -19,12 +19,14 @@ namespace ErsatzTV.Core.Metadata
{
public class MusicVideoFolderScanner : LocalFolderScanner, IMusicVideoFolderScanner
{
private readonly IArtistRepository _artistRepository;
private readonly ILocalFileSystem _localFileSystem;
private readonly ILocalMetadataProvider _localMetadataProvider;
private readonly ILogger<MusicVideoFolderScanner> _logger;
private readonly IMediator _mediator;
private readonly IMusicVideoRepository _musicVideoRepository;
private readonly ISearchIndex _searchIndex;
private readonly ISearchRepository _searchRepository;
public MusicVideoFolderScanner(
ILocalFileSystem localFileSystem,
@@ -33,6 +35,8 @@ namespace ErsatzTV.Core.Metadata
IMetadataRepository metadataRepository,
IImageCache imageCache,
ISearchIndex searchIndex,
ISearchRepository searchRepository,
IArtistRepository artistRepository,
IMusicVideoRepository musicVideoRepository,
IMediator mediator,
ILogger<MusicVideoFolderScanner> logger) : base(
@@ -45,6 +49,8 @@ namespace ErsatzTV.Core.Metadata
_localFileSystem = localFileSystem;
_localMetadataProvider = localMetadataProvider;
_searchIndex = searchIndex;
_searchRepository = searchRepository;
_artistRepository = artistRepository;
_musicVideoRepository = musicVideoRepository;
_mediator = mediator;
_logger = logger;
@@ -64,23 +70,175 @@ namespace ErsatzTV.Core.Metadata
return new MediaSourceInaccessible();
}
var foldersCompleted = 0;
var allArtistFolders = _localFileSystem.ListSubdirectories(libraryPath.Path)
.Filter(ShouldIncludeFolder)
.OrderBy(identity)
.ToList();
var folderQueue = new Queue<string>();
folderQueue.Enqueue(libraryPath.Path);
while (folderQueue.Count > 0)
foreach (string artistFolder in allArtistFolders)
{
decimal percentCompletion = (decimal) foldersCompleted / (foldersCompleted + folderQueue.Count);
// _logger.LogDebug("Scanning artist folder {Folder}", artistFolder);
decimal percentCompletion = (decimal) allArtistFolders.IndexOf(artistFolder) / allArtistFolders.Count;
await _mediator.Publish(
new LibraryScanProgress(libraryPath.LibraryId, progressMin + percentCompletion * progressSpread));
Either<BaseError, MediaItemScanResult<Artist>> maybeArtist =
await FindOrCreateArtist(libraryPath.Id, artistFolder)
.BindT(artist => UpdateMetadataForArtist(artist, artistFolder))
.BindT(artist => UpdateArtworkForArtist(artist, artistFolder, ArtworkKind.Thumbnail))
.BindT(artist => UpdateArtworkForArtist(artist, artistFolder, ArtworkKind.FanArt));
await maybeArtist.Match(
async result =>
{
await ScanMusicVideos(
libraryPath,
ffprobePath,
result.Item,
artistFolder,
// force scanning all folders if we're adding a new artist
result.IsAdded ? DateTimeOffset.MinValue : lastScan);
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 });
}
},
error =>
{
_logger.LogWarning(
"Error processing artist in folder {Folder}: {Error}",
artistFolder,
error.Value);
return Task.FromResult(Unit.Default);
});
}
foreach (string path in await _musicVideoRepository.FindOrphanPaths(libraryPath))
{
_logger.LogInformation("Removing improperly named music video at {Path}", path);
List<int> musicVideoIds = await _musicVideoRepository.DeleteByPath(libraryPath, path);
await _searchIndex.RemoveItems(musicVideoIds);
}
foreach (string path in await _musicVideoRepository.FindMusicVideoPaths(libraryPath))
{
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);
}
}
List<int> artistIds = await _artistRepository.DeleteEmptyArtists(libraryPath);
await _searchIndex.RemoveItems(artistIds);
_searchIndex.Commit();
return Unit.Default;
}
private async Task<Either<BaseError, MediaItemScanResult<Artist>>> FindOrCreateArtist(
int libraryPathId,
string artistFolder)
{
ArtistMetadata metadata = await _localMetadataProvider.GetMetadataForArtist(artistFolder);
Option<Artist> maybeArtist = await _artistRepository.GetArtistByMetadata(libraryPathId, metadata);
return await maybeArtist.Match(
artist => Right<BaseError, MediaItemScanResult<Artist>>(new MediaItemScanResult<Artist>(artist))
.AsTask(),
async () => await _artistRepository.AddArtist(libraryPathId, artistFolder, metadata));
}
private async Task<Either<BaseError, MediaItemScanResult<Artist>>> UpdateMetadataForArtist(
MediaItemScanResult<Artist> result,
string artistFolder)
{
try
{
Artist artist = result.Item;
await LocateNfoFileForArtist(artistFolder).Match(
async nfoFile =>
{
bool shouldUpdate = Optional(artist.ArtistMetadata).Flatten().HeadOrNone().Match(
m => m.MetadataKind == MetadataKind.Fallback ||
m.DateUpdated < _localFileSystem.GetLastWriteTime(nfoFile),
true);
if (shouldUpdate)
{
_logger.LogDebug("Refreshing {Attribute} from {Path}", "Sidecar Metadata", nfoFile);
if (await _localMetadataProvider.RefreshSidecarMetadata(artist, nfoFile))
{
result.IsUpdated = true;
}
}
},
async () =>
{
if (!Optional(artist.ArtistMetadata).Flatten().Any())
{
_logger.LogDebug("Refreshing {Attribute} for {Path}", "Fallback Metadata", artistFolder);
if (await _localMetadataProvider.RefreshFallbackMetadata(artist, artistFolder))
{
result.IsUpdated = true;
}
}
});
return result;
}
catch (Exception ex)
{
return BaseError.New(ex.ToString());
}
}
private async Task<Either<BaseError, MediaItemScanResult<Artist>>> UpdateArtworkForArtist(
MediaItemScanResult<Artist> result,
string artistFolder,
ArtworkKind artworkKind)
{
try
{
Artist artist = result.Item;
await LocateArtworkForArtist(artistFolder, artworkKind).IfSomeAsync(
async artworkFile =>
{
ArtistMetadata metadata = artist.ArtistMetadata.Head();
await RefreshArtwork(artworkFile, metadata, artworkKind);
});
return result;
}
catch (Exception ex)
{
return BaseError.New(ex.ToString());
}
}
public async Task ScanMusicVideos(
LibraryPath libraryPath,
string ffprobePath,
Artist artist,
string artistFolder,
DateTimeOffset lastScan)
{
var folderQueue = new Queue<string>();
folderQueue.Enqueue(artistFolder);
while (folderQueue.Count > 0)
{
string musicVideoFolder = folderQueue.Dequeue();
foldersCompleted++;
// _logger.LogDebug("Scanning music video folder {Folder}", musicVideoFolder);
var allFiles = _localFileSystem.ListFiles(musicVideoFolder)
.Filter(f => VideoFileExtensions.Contains(Path.GetExtension(f)))
.Filter(f => f.Contains(" - "))
.ToList();
foreach (string subdirectory in _localFileSystem.ListSubdirectories(musicVideoFolder)
@@ -98,7 +256,7 @@ namespace ErsatzTV.Core.Metadata
{
// TODO: figure out how to rebuild playouts
Either<BaseError, MediaItemScanResult<MusicVideo>> maybeMusicVideo = await _musicVideoRepository
.GetOrAdd(libraryPath, file)
.GetOrAdd(artist, libraryPath, file)
.BindT(musicVideo => UpdateStatistics(musicVideo, ffprobePath))
.BindT(UpdateMetadata)
.BindT(UpdateThumbnail);
@@ -108,11 +266,11 @@ namespace ErsatzTV.Core.Metadata
{
if (result.IsAdded)
{
await _searchIndex.AddItems(new List<MediaItem> { result.Item });
await _searchIndex.AddItems(_searchRepository, new List<MediaItem> { result.Item });
}
else if (result.IsUpdated)
{
await _searchIndex.UpdateItems(new List<MediaItem> { result.Item });
await _searchIndex.UpdateItems(_searchRepository, new List<MediaItem> { result.Item });
}
},
error =>
@@ -122,18 +280,6 @@ namespace ErsatzTV.Core.Metadata
});
}
}
foreach (string path in await _musicVideoRepository.FindMusicVideoPaths(libraryPath))
{
if (!_localFileSystem.FileExists(path))
{
_logger.LogInformation("Removing missing music video at {Path}", path);
List<int> ids = await _musicVideoRepository.DeleteByPath(libraryPath, path);
await _searchIndex.RemoveItems(ids);
}
}
return Unit.Default;
}
private async Task<Either<BaseError, MediaItemScanResult<MusicVideo>>> UpdateMetadata(
@@ -163,6 +309,8 @@ namespace ErsatzTV.Core.Metadata
{
if (!Optional(musicVideo.MusicVideoMetadata).Flatten().Any())
{
musicVideo.MusicVideoMetadata ??= new List<MusicVideoMetadata>();
string path = musicVideo.MediaVersions.Head().MediaFiles.Head().Path;
_logger.LogDebug("Refreshing {Attribute} for {Path}", "Fallback Metadata", path);
if (await _localMetadataProvider.RefreshFallbackMetadata(musicVideo))
@@ -180,6 +328,26 @@ namespace ErsatzTV.Core.Metadata
}
}
private Option<string> LocateNfoFileForArtist(string artistFolder) =>
Optional(Path.Combine(artistFolder, "artist.nfo"))
.Filter(s => _localFileSystem.FileExists(s));
private Option<string> LocateArtworkForArtist(string artistFolder, ArtworkKind artworkKind)
{
string segment = artworkKind switch
{
ArtworkKind.Thumbnail => "thumb",
ArtworkKind.FanArt => "fanart",
_ => throw new ArgumentOutOfRangeException(nameof(artworkKind))
};
return ImageFileExtensions
.Map(ext => $"{segment}.{ext}")
.Map(f => Path.Combine(artistFolder, f))
.Filter(s => _localFileSystem.FileExists(s))
.HeadOrNone();
}
private Option<string> LocateNfoFile(MusicVideo musicVideo)
{
string path = musicVideo.MediaVersions.Head().MediaFiles.Head().Path;
+27
View File
@@ -0,0 +1,27 @@
using System.Collections.Generic;
using System.Xml.Serialization;
namespace ErsatzTV.Core.Metadata.Nfo
{
[XmlRoot("artist")]
public class ArtistNfo
{
[XmlElement("name")]
public string Name { get; set; }
[XmlElement("disambiguation")]
public string Disambiguation { get; set; }
[XmlElement("genre")]
public List<string> Genres { get; set; }
[XmlElement("style")]
public List<string> Styles { get; set; }
[XmlElement("mood")]
public List<string> Moods { get; set; }
[XmlElement("biography")]
public string Biography { get; set; }
}
}
@@ -24,6 +24,7 @@ namespace ErsatzTV.Core.Metadata
private readonly ILogger<TelevisionFolderScanner> _logger;
private readonly IMediator _mediator;
private readonly ISearchIndex _searchIndex;
private readonly ISearchRepository _searchRepository;
private readonly ITelevisionRepository _televisionRepository;
public TelevisionFolderScanner(
@@ -34,6 +35,7 @@ namespace ErsatzTV.Core.Metadata
IMetadataRepository metadataRepository,
IImageCache imageCache,
ISearchIndex searchIndex,
ISearchRepository searchRepository,
IMediator mediator,
ILogger<TelevisionFolderScanner> logger) : base(
localFileSystem,
@@ -46,6 +48,7 @@ namespace ErsatzTV.Core.Metadata
_televisionRepository = televisionRepository;
_localMetadataProvider = localMetadataProvider;
_searchIndex = searchIndex;
_searchRepository = searchRepository;
_mediator = mediator;
_logger = logger;
}
@@ -84,15 +87,6 @@ namespace ErsatzTV.Core.Metadata
await maybeShow.Match(
async result =>
{
if (result.IsAdded)
{
await _searchIndex.AddItems(new List<MediaItem> { result.Item });
}
else if (result.IsUpdated)
{
await _searchIndex.UpdateItems(new List<MediaItem> { result.Item });
}
await ScanSeasons(
libraryPath,
ffprobePath,
@@ -100,6 +94,15 @@ namespace ErsatzTV.Core.Metadata
showFolder,
// force scanning all folders if we're adding a new show
result.IsAdded ? DateTimeOffset.MinValue : lastScan);
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 });
}
},
error =>
{
@@ -124,6 +127,7 @@ namespace ErsatzTV.Core.Metadata
List<int> ids = await _televisionRepository.DeleteEmptyShows(libraryPath);
await _searchIndex.RemoveItems(ids);
_searchIndex.Commit();
return Unit.Default;
}
@@ -397,10 +401,6 @@ namespace ErsatzTV.Core.Metadata
.HeadOrNone();
}
private bool ShouldIncludeFolder(string folder) =>
!Path.GetFileName(folder).StartsWith('.') &&
!_localFileSystem.FileExists(Path.Combine(folder, ".etvignore"));
private static Option<int> SeasonNumberForFolder(string folder)
{
if (int.TryParse(folder.Split(" ").Last(), out int seasonNumber))
@@ -21,12 +21,14 @@ namespace ErsatzTV.Core.Plex
private readonly IMovieRepository _movieRepository;
private readonly IPlexServerApiClient _plexServerApiClient;
private readonly ISearchIndex _searchIndex;
private readonly ISearchRepository _searchRepository;
public PlexMovieLibraryScanner(
IPlexServerApiClient plexServerApiClient,
IMovieRepository movieRepository,
IMetadataRepository metadataRepository,
ISearchIndex searchIndex,
ISearchRepository searchRepository,
IMediator mediator,
ILogger<PlexMovieLibraryScanner> logger)
: base(metadataRepository, logger)
@@ -35,6 +37,7 @@ namespace ErsatzTV.Core.Plex
_movieRepository = movieRepository;
_metadataRepository = metadataRepository;
_searchIndex = searchIndex;
_searchRepository = searchRepository;
_mediator = mediator;
_logger = logger;
}
@@ -69,11 +72,13 @@ namespace ErsatzTV.Core.Plex
{
if (result.IsAdded)
{
await _searchIndex.AddItems(new List<MediaItem> { result.Item });
await _searchIndex.AddItems(_searchRepository, new List<MediaItem> { result.Item });
}
else if (result.IsUpdated)
{
await _searchIndex.UpdateItems(new List<MediaItem> { result.Item });
await _searchIndex.UpdateItems(
_searchRepository,
new List<MediaItem> { result.Item });
}
},
error =>
@@ -102,6 +107,7 @@ namespace ErsatzTV.Core.Plex
return Task.CompletedTask;
});
_searchIndex.Commit();
return Unit.Default;
}
@@ -21,6 +21,7 @@ namespace ErsatzTV.Core.Plex
private readonly IMetadataRepository _metadataRepository;
private readonly IPlexServerApiClient _plexServerApiClient;
private readonly ISearchIndex _searchIndex;
private readonly ISearchRepository _searchRepository;
private readonly ITelevisionRepository _televisionRepository;
public PlexTelevisionLibraryScanner(
@@ -28,6 +29,7 @@ namespace ErsatzTV.Core.Plex
ITelevisionRepository televisionRepository,
IMetadataRepository metadataRepository,
ISearchIndex searchIndex,
ISearchRepository searchRepository,
IMediator mediator,
ILogger<PlexTelevisionLibraryScanner> logger)
: base(metadataRepository, logger)
@@ -36,6 +38,7 @@ namespace ErsatzTV.Core.Plex
_televisionRepository = televisionRepository;
_metadataRepository = metadataRepository;
_searchIndex = searchIndex;
_searchRepository = searchRepository;
_mediator = mediator;
_logger = logger;
}
@@ -67,16 +70,18 @@ namespace ErsatzTV.Core.Plex
await maybeShow.Match(
async result =>
{
await ScanSeasons(plexMediaSourceLibrary, result.Item, connection, token);
if (result.IsAdded)
{
await _searchIndex.AddItems(new List<MediaItem> { result.Item });
await _searchIndex.AddItems(_searchRepository, new List<MediaItem> { result.Item });
}
else if (result.IsUpdated)
{
await _searchIndex.UpdateItems(new List<MediaItem> { result.Item });
await _searchIndex.UpdateItems(
_searchRepository,
new List<MediaItem> { result.Item });
}
await ScanSeasons(plexMediaSourceLibrary, result.Item, connection, token);
},
error =>
{
@@ -95,6 +100,7 @@ namespace ErsatzTV.Core.Plex
await _mediator.Publish(new LibraryScanProgress(plexMediaSourceLibrary.Id, 0));
_searchIndex.Commit();
return Unit.Default;
},
error =>
+21 -13
View File
@@ -475,20 +475,28 @@ namespace ErsatzTV.Core.Scheduling
}
}
private static string DisplayTitle(MediaItem mediaItem) =>
mediaItem switch
private static string DisplayTitle(MediaItem mediaItem)
{
switch (mediaItem)
{
Episode e => e.EpisodeMetadata.Any() && e.Season != null
? $"{e.EpisodeMetadata.Head().Title} - s{e.Season.SeasonNumber:00}e{e.EpisodeNumber:00}"
: "[unknown episode]",
Movie m => m.MovieMetadata.HeadOrNone().Match(
mm => mm.Title ?? string.Empty,
() => "[unknown movie]"),
MusicVideo mv => mv.MusicVideoMetadata.HeadOrNone().Match(
mvm => $"{mvm.Artist} - {mvm.Title}",
() => "[unknown music video]"),
_ => string.Empty
};
case Episode e:
string showTitle = e.Season.Show.ShowMetadata.HeadOrNone()
.Map(sm => $"{sm.Title} - ").IfNone(string.Empty);
return e.EpisodeMetadata.HeadOrNone()
.Map(em => $"{showTitle}s{e.Season.SeasonNumber:00}e{e.EpisodeNumber:00} - {em.Title}")
.IfNone("[unknown episode]");
case Movie m:
return m.MovieMetadata.HeadOrNone().Match(mm => mm.Title ?? string.Empty, () => "[unknown movie]");
case MusicVideo mv:
string artistName = mv.Artist.ArtistMetadata.HeadOrNone()
.Map(am => $"{am.Title} - ").IfNone(string.Empty);
return mv.MusicVideoMetadata.HeadOrNone()
.Map(mvm => $"{artistName}{mvm.Title}")
.IfNone("[unknown music video]");
default:
return string.Empty;
}
}
private static CollectionKey CollectionKeyForItem(ProgramScheduleItem item) =>
item.CollectionType switch
@@ -0,0 +1,24 @@
using ErsatzTV.Core.Domain;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ErsatzTV.Infrastructure.Data.Configurations
{
public class ArtistConfiguration : IEntityTypeConfiguration<Artist>
{
public void Configure(EntityTypeBuilder<Artist> builder)
{
builder.ToTable("Artist");
builder.HasMany(a => a.MusicVideos)
.WithOne(mv => mv.Artist)
.HasForeignKey(mv => mv.ArtistId)
.OnDelete(DeleteBehavior.Cascade);
builder.HasMany(a => a.ArtistMetadata)
.WithOne(am => am.Artist)
.HasForeignKey(am => am.ArtistId)
.OnDelete(DeleteBehavior.Cascade);
}
}
}
@@ -0,0 +1,30 @@
using ErsatzTV.Core.Domain;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ErsatzTV.Infrastructure.Data.Configurations
{
public class ArtistMetadataConfiguration : IEntityTypeConfiguration<ArtistMetadata>
{
public void Configure(EntityTypeBuilder<ArtistMetadata> builder)
{
builder.ToTable("ArtistMetadata");
builder.HasMany(sm => sm.Artwork)
.WithOne()
.OnDelete(DeleteBehavior.Cascade);
builder.HasMany(sm => sm.Genres)
.WithOne()
.OnDelete(DeleteBehavior.Cascade);
builder.HasMany(sm => sm.Styles)
.WithOne()
.OnDelete(DeleteBehavior.Cascade);
builder.HasMany(sm => sm.Moods)
.WithOne()
.OnDelete(DeleteBehavior.Cascade);
}
}
}
@@ -0,0 +1,150 @@
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 ArtistRepository : IArtistRepository
{
private readonly IDbConnection _dbConnection;
private readonly IDbContextFactory<TvContext> _dbContextFactory;
public ArtistRepository(IDbContextFactory<TvContext> dbContextFactory, IDbConnection dbConnection)
{
_dbContextFactory = dbContextFactory;
_dbConnection = dbConnection;
}
public async Task<Option<Artist>> GetArtistByMetadata(int libraryPathId, ArtistMetadata metadata)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
Option<int> maybeId = await dbContext.ArtistMetadata
.Where(
s => s.Title == metadata.Title && (metadata.MetadataKind == MetadataKind.Fallback ||
s.Disambiguation == metadata.Disambiguation))
.Where(s => s.Artist.LibraryPathId == libraryPathId)
.SingleOrDefaultAsync()
.Map(Optional)
.MapT(sm => sm.ArtistId);
return await maybeId.Match(
id =>
{
return dbContext.Artists
.AsNoTracking()
.Include(s => s.ArtistMetadata)
.ThenInclude(sm => sm.Artwork)
.Include(s => s.ArtistMetadata)
.ThenInclude(sm => sm.Genres)
.Include(s => s.ArtistMetadata)
.ThenInclude(sm => sm.Styles)
.Include(s => s.ArtistMetadata)
.ThenInclude(sm => sm.Moods)
.Include(s => s.LibraryPath)
.ThenInclude(lp => lp.Library)
.OrderBy(s => s.Id)
.SingleOrDefaultAsync(s => s.Id == id)
.Map(Optional);
},
() => Option<Artist>.None.AsTask());
}
public async Task<Either<BaseError, MediaItemScanResult<Artist>>> AddArtist(
int libraryPathId,
string artistFolder,
ArtistMetadata metadata)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
try
{
metadata.DateAdded = DateTime.UtcNow;
metadata.Genres ??= new List<Genre>();
metadata.Styles ??= new List<Style>();
metadata.Moods ??= new List<Mood>();
var artist = new Artist
{
LibraryPathId = libraryPathId,
ArtistMetadata = new List<ArtistMetadata> { metadata }
};
await dbContext.Artists.AddAsync(artist);
await dbContext.SaveChangesAsync();
await dbContext.Entry(artist).Reference(s => s.LibraryPath).LoadAsync();
await dbContext.Entry(artist.LibraryPath).Reference(lp => lp.Library).LoadAsync();
return new MediaItemScanResult<Artist>(artist) { IsAdded = true };
}
catch (Exception ex)
{
return BaseError.New(ex.Message);
}
}
public async Task<List<int>> DeleteEmptyArtists(LibraryPath libraryPath)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
List<Artist> artists = await dbContext.Artists
.Filter(a => a.LibraryPathId == libraryPath.Id)
.Filter(a => a.MusicVideos.Count == 0)
.ToListAsync();
var ids = artists.Map(a => a.Id).ToList();
dbContext.Artists.RemoveRange(artists);
await dbContext.SaveChangesAsync();
return ids;
}
public async Task<Option<Artist>> GetArtist(int artistId)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
return await dbContext.Artists
.Include(m => m.ArtistMetadata)
.ThenInclude(m => m.Artwork)
.Include(m => m.ArtistMetadata)
.ThenInclude(m => m.Genres)
.Include(m => m.ArtistMetadata)
.ThenInclude(m => m.Styles)
.Include(m => m.ArtistMetadata)
.ThenInclude(m => m.Moods)
.OrderBy(m => m.Id)
.SingleOrDefaultAsync(m => m.Id == artistId)
.Map(Optional);
}
public async Task<List<ArtistMetadata>> GetArtistsForCards(List<int> ids)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
return await dbContext.ArtistMetadata
.AsNoTracking()
.Filter(am => ids.Contains(am.ArtistId))
.Include(am => am.Artwork)
.OrderBy(am => am.SortTitle)
.ToListAsync();
}
public Task<bool> AddGenre(ArtistMetadata metadata, Genre genre) =>
_dbConnection.ExecuteAsync(
"INSERT INTO Genre (Name, ArtistMetadataId) VALUES (@Name, @MetadataId)",
new { genre.Name, MetadataId = metadata.Id }).Map(result => result > 0);
public Task<bool> AddStyle(ArtistMetadata metadata, Style style) =>
_dbConnection.ExecuteAsync(
"INSERT INTO Style (Name, ArtistMetadataId) VALUES (@Name, @MetadataId)",
new { style.Name, MetadataId = metadata.Id }).Map(result => result > 0);
public Task<bool> AddMood(ArtistMetadata metadata, Mood mood) =>
_dbConnection.ExecuteAsync(
"INSERT INTO Mood (Name, ArtistMetadataId) VALUES (@Name, @MetadataId)",
new { mood.Name, MetadataId = metadata.Id }).Map(result => result > 0);
}
}
@@ -15,10 +15,15 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
{
private readonly IDbConnection _dbConnection;
private readonly TvContext _dbContext;
private readonly IDbContextFactory<TvContext> _dbContextFactory;
public MediaCollectionRepository(TvContext dbContext, IDbConnection dbConnection)
public MediaCollectionRepository(
TvContext dbContext,
IDbContextFactory<TvContext> dbContextFactory,
IDbConnection dbConnection)
{
_dbContext = dbContext;
_dbContextFactory = dbContextFactory;
_dbConnection = dbConnection;
}
@@ -29,7 +34,6 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
return collection;
}
public async Task<bool> AddMediaItem(int collectionId, int mediaItemId)
{
var modified = false;
@@ -130,9 +134,15 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.ThenInclude(i => (i as Movie).MovieMetadata)
.ThenInclude(mm => mm.Artwork)
.Include(c => c.MediaItems)
.ThenInclude(i => (i as Artist).ArtistMetadata)
.ThenInclude(mvm => mvm.Artwork)
.Include(c => c.MediaItems)
.ThenInclude(i => (i as MusicVideo).MusicVideoMetadata)
.ThenInclude(mvm => mvm.Artwork)
.Include(c => c.MediaItems)
.ThenInclude(i => (i as MusicVideo).Artist)
.ThenInclude(a => a.ArtistMetadata)
.Include(c => c.MediaItems)
.ThenInclude(i => (i as Show).ShowMetadata)
.ThenInclude(sm => sm.Artwork)
.Include(c => c.MediaItems)
@@ -162,6 +172,22 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
public Task<List<Collection>> GetAll() =>
_dbContext.Collections.ToListAsync();
public Task<int> CountAllCollections() =>
_dbConnection.QuerySingleAsync<int>(@"SELECT COUNT (*) FROM Collection");
public async Task<List<Collection>> GetPagedCollections(int pageNumber, int pageSize)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
return await dbContext.Collections.FromSqlRaw(
@"SELECT * FROM Collection
ORDER BY Name
LIMIT {0} OFFSET {1}",
pageSize,
(pageNumber - 1) * pageSize)
.AsNoTracking()
.ToListAsync();
}
public Task<Option<List<MediaItem>>> GetItems(int id) =>
Get(id).MapT(GetItemsForCollection).Bind(x => x.Sequence());
@@ -201,6 +227,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
result.AddRange(await GetShowItems(collection));
result.AddRange(await GetSeasonItems(collection));
result.AddRange(await GetEpisodeItems(collection));
result.AddRange(await GetArtistItems(collection));
result.AddRange(await GetMusicVideoItems(collection));
return result.Distinct().ToList();
@@ -221,6 +248,25 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.ToListAsync();
}
private async Task<List<MusicVideo>> GetArtistItems(Collection collection)
{
IEnumerable<int> ids = await _dbConnection.QueryAsync<int>(
@"SELECT MusicVideo.Id FROM CollectionItem ci
INNER JOIN Artist on Artist.Id = ci.MediaItemId
INNER JOIN MusicVideo on Artist.Id = MusicVideo.ArtistId
WHERE ci.CollectionId = @CollectionId",
new { CollectionId = collection.Id });
return await _dbContext.MusicVideos
.Include(m => m.Artist)
.ThenInclude(a => a.ArtistMetadata)
.Include(m => m.MusicVideoMetadata)
.Include(m => m.MediaVersions)
.Filter(m => ids.Contains(m.Id))
.ToListAsync();
}
private async Task<List<MusicVideo>> GetMusicVideoItems(Collection collection)
{
IEnumerable<int> ids = await _dbConnection.QueryAsync<int>(
@@ -230,6 +276,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
new { CollectionId = collection.Id });
return await _dbContext.MusicVideos
.Include(m => m.Artist)
.ThenInclude(a => a.ArtistMetadata)
.Include(m => m.MusicVideoMetadata)
.Include(m => m.MediaVersions)
.Filter(m => ids.Contains(m.Id))
@@ -250,6 +298,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.Include(e => e.EpisodeMetadata)
.Include(e => e.MediaVersions)
.Include(e => e.Season)
.ThenInclude(s => s.Show)
.ThenInclude(s => s.ShowMetadata)
.Filter(e => ids.Contains(e.Id))
.ToListAsync();
}
@@ -267,6 +317,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.Include(e => e.EpisodeMetadata)
.Include(e => e.MediaVersions)
.Include(e => e.Season)
.ThenInclude(s => s.Show)
.ThenInclude(s => s.ShowMetadata)
.Filter(e => ids.Contains(e.Id))
.ToListAsync();
}
@@ -283,6 +335,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.Include(e => e.EpisodeMetadata)
.Include(e => e.MediaVersions)
.Include(e => e.Season)
.ThenInclude(s => s.Show)
.ThenInclude(s => s.ShowMetadata)
.Filter(e => ids.Contains(e.Id))
.ToListAsync();
}
@@ -155,6 +155,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
public async Task Update(
PlexMediaSource plexMediaSource,
List<PlexConnection> sortedConnections,
List<PlexConnection> toAdd,
List<PlexConnection> toDelete)
{
@@ -174,27 +175,34 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
plexMediaSource.Id
});
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
foreach (PlexConnection add in toAdd)
{
add.PlexMediaSourceId = plexMediaSource.Id;
dbContext.Entry(add).State = EntityState.Added;
await _dbConnection.ExecuteAsync(
@"INSERT INTO PlexConnection (IsActive, Uri, PlexMediaSourceId)
VALUES (0, @Uri, @PlexMediaSourceId)",
new { add.Uri, PlexMediaSourceId = plexMediaSource.Id });
}
foreach (PlexConnection delete in toDelete)
{
dbContext.Entry(delete).State = EntityState.Deleted;
await _dbConnection.ExecuteAsync(
@"DELETE FROM PlexConnection WHERE Id = @Id",
new { delete.Id });
}
await dbContext.SaveChangesAsync();
PlexMediaSource pms = await dbContext.PlexMediaSources.FindAsync(plexMediaSource.Id);
await dbContext.Entry(pms).Collection(x => x.Connections).LoadAsync();
if (plexMediaSource.Connections.Any() && plexMediaSource.Connections.All(c => !c.IsActive))
int activeCount = await _dbConnection.QuerySingleAsync<int>(
@"SELECT COUNT(*) FROM PlexConnection WHERE IsActive = 1 AND PlexMediaSourceId = @PlexMediaSourceId",
new { PlexMediaSourceId = plexMediaSource.Id });
if (activeCount == 0)
{
plexMediaSource.Connections.Head().IsActive = true;
await dbContext.SaveChangesAsync();
Option<PlexConnection> toActivate =
sortedConnections.FirstOrDefault(c => toDelete.All(d => d.Id != c.Id));
// update on uri because connections from Plex API don't have our local ids
await toActivate.IfSomeAsync(
async c => await _dbConnection.ExecuteAsync(
@"UPDATE PlexConnection SET IsActive = 1 WHERE Uri = @Uri",
new { c.Uri }));
}
}
@@ -292,6 +300,23 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
return movieIds.Append(showIds).ToList();
}
public async Task<List<int>> DeletePlex(PlexMediaSource plexMediaSource)
{
List<int> mediaItemIds = await _dbConnection.QueryAsync<int>(
@"SELECT MediaItem.Id FROM MediaItem
INNER JOIN LibraryPath LP on MediaItem.LibraryPathId = LP.Id
INNER JOIN Library L on LP.LibraryId = L.Id
WHERE L.MediaSourceId = @PlexMediaSourceId",
new { PlexMediaSourceId = plexMediaSource.Id })
.Map(result => result.ToList());
await _dbConnection.ExecuteAsync(
@"DELETE FROM MediaSource WHERE Id = @PlexMediaSourceId",
new { PlexMediaSourceId = plexMediaSource.Id });
return mediaItemIds;
}
public async Task<List<int>> DisablePlexLibrarySync(List<int> libraryIds)
{
await _dbConnection.ExecuteAsync(
@@ -156,6 +156,11 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
VALUES (@ArtworkKind, @Id, @DateAdded, @DateUpdated, @Path)",
parameters)
.ToUnit(),
ArtistMetadata => _dbConnection.ExecuteAsync(
@"INSERT INTO Artwork (ArtworkKind, ArtistMetadataId, DateAdded, DateUpdated, Path)
Values (@ArtworkKind, @Id, @DateAdded, @DateUpdated, @Path)",
parameters)
.ToUnit(),
MusicVideoMetadata => _dbConnection.ExecuteAsync(
@"INSERT INTO Artwork (ArtworkKind, MusicVideoMetadataId, DateAdded, DateUpdated, Path)
VALUES (@ArtworkKind, @Id, @DateAdded, @DateUpdated, @Path)",
@@ -197,5 +202,13 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
public Task<bool> RemoveStudio(Studio studio) =>
_dbConnection.ExecuteAsync("DELETE FROM Studio WHERE Id = @StudioId", new { StudioId = studio.Id })
.Map(result => result > 0);
public Task<bool> RemoveStyle(Style style) =>
_dbConnection.ExecuteAsync("DELETE FROM Style WHERE Id = @StyleId", new { StyleId = style.Id })
.Map(result => result > 0);
public Task<bool> RemoveMood(Mood mood) =>
_dbConnection.ExecuteAsync("DELETE FROM Mood WHERE Id = @MoodId", new { MoodId = mood.Id })
.Map(result => result > 0);
}
}
@@ -26,6 +26,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
}
public async Task<Either<BaseError, MediaItemScanResult<MusicVideo>>> GetOrAdd(
Artist artist,
LibraryPath libraryPath,
string path)
{
@@ -50,10 +51,22 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.SingleOrDefaultAsync(i => i.MediaVersions.First().MediaFiles.First().Path == path);
return await maybeExisting.Match(
mediaItem =>
Right<BaseError, MediaItemScanResult<MusicVideo>>(
new MediaItemScanResult<MusicVideo>(mediaItem) { IsAdded = false }).AsTask(),
async () => await AddMusicVideo(dbContext, libraryPath.Id, path));
async mediaItem =>
{
if (mediaItem.ArtistId != artist.Id)
{
await _dbConnection.ExecuteAsync(
@"UPDATE MusicVideo SET ArtistId = @ArtistId WHERE Id = @Id",
new { mediaItem.Id, ArtistId = artist.Id });
mediaItem.ArtistId = artist.Id;
mediaItem.Artist = artist;
}
return Right<BaseError, MediaItemScanResult<MusicVideo>>(
new MediaItemScanResult<MusicVideo>(mediaItem) { IsAdded = false });
},
async () => await AddMusicVideo(dbContext, artist, libraryPath.Id, path));
}
public Task<IEnumerable<string>> FindMusicVideoPaths(LibraryPath libraryPath) =>
@@ -72,7 +85,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
@"SELECT M.Id
FROM MusicVideo M
INNER JOIN MediaItem MI on M.Id = MI.Id
INNER JOIN MediaVersion MV on M.Id = MV.EpisodeId
INNER JOIN MediaVersion MV on M.Id = MV.MusicVideoId
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());
@@ -110,6 +123,9 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
return await dbContext.MusicVideoMetadata
.AsNoTracking()
.Filter(mvm => ids.Contains(mvm.MusicVideoId))
.Include(mvm => mvm.MusicVideo)
.ThenInclude(mv => mv.Artist)
.ThenInclude(a => a.ArtistMetadata)
.Include(mvm => mvm.Artwork)
.OrderBy(mvm => mvm.SortTitle)
.ToListAsync();
@@ -132,8 +148,44 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.Map(Optional);
}
public Task<IEnumerable<string>> FindOrphanPaths(LibraryPath libraryPath) =>
_dbConnection.QueryAsync<string>(
@"SELECT MF.Path
FROM MediaFile MF
INNER JOIN MediaVersion MV on MF.MediaVersionId = MV.Id
INNER JOIN MusicVideo M on MV.MusicVideoId = M.Id
INNER JOIN MediaItem MI on M.Id = MI.Id
WHERE MI.LibraryPathId = @LibraryPathId
AND NOT EXISTS (SELECT * FROM MusicVideoMetadata WHERE MusicVideoId = M.Id)",
new { LibraryPathId = libraryPath.Id });
public Task<int> GetMusicVideoCount(int artistId) =>
_dbConnection.QuerySingleAsync<int>(
@"SELECT COUNT(*) FROM MusicVideo WHERE ArtistId = @ArtistId",
new { ArtistId = artistId });
public async Task<List<MusicVideoMetadata>> GetPagedMusicVideos(int artistId, int pageNumber, int pageSize)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
return await dbContext.MusicVideoMetadata
.AsNoTracking()
.Include(m => m.Artwork)
.Include(m => m.Genres)
.Include(m => m.Tags)
.Include(m => m.Studios)
.Include(m => m.MusicVideo)
.ThenInclude(mv => mv.Artist)
.ThenInclude(a => a.ArtistMetadata)
.Filter(m => m.MusicVideo.ArtistId == artistId)
.OrderBy(m => m.SortTitle)
.Skip((pageNumber - 1) * pageSize)
.Take(pageSize)
.ToListAsync();
}
private static async Task<Either<BaseError, MediaItemScanResult<MusicVideo>>> AddMusicVideo(
TvContext dbContext,
Artist artist,
int libraryPathId,
string path)
{
@@ -141,6 +193,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
{
var musicVideo = new MusicVideo
{
ArtistId = artist.Id,
LibraryPathId = libraryPathId,
MediaVersions = new List<MediaVersion>
{
@@ -101,6 +101,9 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.Include(i => i.MediaItem)
.ThenInclude(mi => (mi as MusicVideo).MediaVersions)
.Include(i => i.MediaItem)
.ThenInclude(mi => (mi as MusicVideo).Artist)
.ThenInclude(mm => mm.ArtistMetadata)
.Include(i => i.MediaItem)
.ThenInclude(mi => (mi as Episode).EpisodeMetadata)
.ThenInclude(em => em.Artwork)
.Include(i => i.MediaItem)
@@ -39,94 +39,51 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.ThenInclude(mm => mm.Tags)
.Include(mi => (mi as Movie).MovieMetadata)
.ThenInclude(mm => mm.Studios)
.Include(mi => (mi as Movie).MediaVersions)
.ThenInclude(mm => mm.Streams)
.Include(mi => (mi as Show).ShowMetadata)
.ThenInclude(mm => mm.Genres)
.Include(mi => (mi as Show).ShowMetadata)
.ThenInclude(mm => mm.Tags)
.Include(mi => (mi as Show).ShowMetadata)
.ThenInclude(mm => mm.Studios)
.Include(mi => (mi as MusicVideo).MusicVideoMetadata)
.ThenInclude(mm => mm.Genres)
.Include(mi => (mi as MusicVideo).MusicVideoMetadata)
.ThenInclude(mm => mm.Tags)
.Include(mi => (mi as MusicVideo).MusicVideoMetadata)
.ThenInclude(mm => mm.Studios)
.Include(mi => (mi as MusicVideo).MediaVersions)
.ThenInclude(mm => mm.Streams)
.Include(mi => (mi as Artist).ArtistMetadata)
.ThenInclude(mm => mm.Genres)
.Include(mi => (mi as Artist).ArtistMetadata)
.ThenInclude(mm => mm.Styles)
.Include(mi => (mi as Artist).ArtistMetadata)
.ThenInclude(mm => mm.Moods)
.OrderBy(mi => mi.Id)
.SingleOrDefaultAsync(mi => mi.Id == id)
.Map(Optional);
}
public async Task<List<MediaItem>> SearchMediaItemsByTitle(string query)
{
List<int> ids = await _dbConnection.QueryAsync<int>(
@"SELECT M.Id FROM Movie M
INNER JOIN MovieMetadata MM on M.Id = MM.MovieId
WHERE MM.Title LIKE @Query
UNION
SELECT S.Id FROM Show S
INNER JOIN ShowMetadata SM on S.Id = SM.ShowId
WHERE SM.Title LIKE @Query
GROUP BY SM.Title, SM.Year",
new { Query = $"%{query}%" })
.Map(results => results.ToList());
public Task<List<string>> GetLanguagesForShow(Show show) =>
_dbConnection.QueryAsync<string>(
@"SELECT DISTINCT Language
FROM MediaStream
INNER JOIN MediaVersion MV ON MediaStream.MediaVersionId = MV.Id
INNER JOIN Episode E ON MV.EpisodeId = E.Id
INNER JOIN Season S ON E.SeasonId = S.Id
WHERE MediaStreamKind = 2 AND S.ShowId = @ShowId",
new { ShowId = show.Id }).Map(result => result.ToList());
await using TvContext context = _dbContextFactory.CreateDbContext();
return await context.MediaItems
.Filter(m => ids.Contains(m.Id))
.Include(m => (m as Movie).MovieMetadata)
.ThenInclude(mm => mm.Artwork)
.Include(m => (m as Show).ShowMetadata)
.ThenInclude(mm => mm.Artwork)
.OfType<MediaItem>()
.ToListAsync();
}
public async Task<List<MediaItem>> SearchMediaItemsByGenre(string genre)
{
List<int> ids = await _dbConnection.QueryAsync<int>(
@"SELECT M.Id FROM Movie M
INNER JOIN MovieMetadata MM on M.Id = MM.MovieId
INNER JOIN Genre G on MM.Id = G.MovieMetadataId
WHERE G.Name LIKE @Query
UNION
SELECT S.Id FROM Show S
INNER JOIN ShowMetadata SM on S.Id = SM.ShowId
INNER JOIN Genre G2 on SM.Id = G2.ShowMetadataId
WHERE G2.Name LIKE @Query
GROUP BY SM.Title, SM.Year",
new { Query = genre })
.Map(results => results.ToList());
await using TvContext context = _dbContextFactory.CreateDbContext();
return await context.MediaItems
.Filter(m => ids.Contains(m.Id))
.Include(m => (m as Movie).MovieMetadata)
.ThenInclude(mm => mm.Artwork)
.Include(m => (m as Show).ShowMetadata)
.ThenInclude(mm => mm.Artwork)
.OfType<MediaItem>()
.ToListAsync();
}
public async Task<List<MediaItem>> SearchMediaItemsByTag(string tag)
{
List<int> ids = await _dbConnection.QueryAsync<int>(
@"SELECT M.Id FROM Movie M
INNER JOIN MovieMetadata MM on M.Id = MM.MovieId
INNER JOIN Tag T on MM.Id = T.MovieMetadataId
WHERE T.Name LIKE @Query
UNION
SELECT S.Id FROM Show S
INNER JOIN ShowMetadata SM on S.Id = SM.ShowId
INNER JOIN Tag T2 on SM.Id = T2.ShowMetadataId
WHERE T2.Name LIKE @Query
GROUP BY SM.Title, SM.Year",
new { Query = tag })
.Map(results => results.ToList());
await using TvContext context = _dbContextFactory.CreateDbContext();
return await context.MediaItems
.Filter(m => ids.Contains(m.Id))
.Include(m => (m as Movie).MovieMetadata)
.ThenInclude(mm => mm.Artwork)
.Include(m => (m as Show).ShowMetadata)
.ThenInclude(mm => mm.Artwork)
.OfType<MediaItem>()
.ToListAsync();
}
public Task<List<string>> GetLanguagesForArtist(Artist artist) =>
_dbConnection.QueryAsync<string>(
@"SELECT DISTINCT Language
FROM MediaStream
INNER JOIN MediaVersion V ON MediaStream.MediaVersionId = V.Id
INNER JOIN MusicVideo MV ON V.MusicVideoId = MV.Id
INNER JOIN Artist A on MV.ArtistId = A.Id
WHERE MediaStreamKind = 2 AND A.Id = @ArtistId",
new { ArtistId = artist.Id }).Map(result => result.ToList());
}
}
@@ -472,6 +472,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.Include(e => e.EpisodeMetadata)
.Include(e => e.MediaVersions)
.Include(e => e.Season)
.ThenInclude(s => s.Show)
.ThenInclude(s => s.ShowMetadata)
.Filter(e => ids.Contains(e.Id))
.ToListAsync();
}
@@ -484,6 +486,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.Include(e => e.EpisodeMetadata)
.Include(e => e.MediaVersions)
.Include(e => e.Season)
.ThenInclude(s => s.Show)
.ThenInclude(s => s.ShowMetadata)
.Filter(e => e.SeasonId == seasonId)
.ToListAsync();
}
@@ -27,6 +27,8 @@ namespace ErsatzTV.Infrastructure.Data
public DbSet<MediaFile> MediaFiles { get; set; }
public DbSet<Movie> Movies { get; set; }
public DbSet<MovieMetadata> MovieMetadata { get; set; }
public DbSet<Artist> Artists { get; set; }
public DbSet<ArtistMetadata> ArtistMetadata { get; set; }
public DbSet<MusicVideo> MusicVideos { get; set; }
public DbSet<MusicVideoMetadata> MusicVideoMetadata { get; set; }
public DbSet<Show> Shows { get; set; }
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,38 @@
using Microsoft.EntityFrameworkCore.Migrations;
namespace ErsatzTV.Infrastructure.Migrations
{
public partial class Delete_Orphan_GenreTagStudio : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql(
@"DELETE FROM Genre
WHERE MovieMetadataId NOT IN (SELECT Id FROM MovieMetadata)
OR ShowMetadataId NOT IN (SELECT Id FROM Show)
OR SeasonMetadataId NOT IN (SELECT Id FROM Season)
OR EpisodeMetadataId NOT IN (SELECT Id FROM Episode)
OR MusicVideoMetadataId NOT IN (SELECT Id FROM MusicVideoMetadata)");
migrationBuilder.Sql(
@"DELETE FROM Tag
WHERE MovieMetadataId NOT IN (SELECT Id FROM MovieMetadata)
OR ShowMetadataId NOT IN (SELECT Id FROM Show)
OR SeasonMetadataId NOT IN (SELECT Id FROM Season)
OR EpisodeMetadataId NOT IN (SELECT Id FROM Episode)
OR MusicVideoMetadataId NOT IN (SELECT Id FROM MusicVideoMetadata)");
migrationBuilder.Sql(
@"DELETE FROM Studio
WHERE MovieMetadataId NOT IN (SELECT Id FROM MovieMetadata)
OR ShowMetadataId NOT IN (SELECT Id FROM Show)
OR SeasonMetadataId NOT IN (SELECT Id FROM Season)
OR EpisodeMetadataId NOT IN (SELECT Id FROM Episode)
OR MusicVideoMetadataId NOT IN (SELECT Id FROM MusicVideoMetadata)");
}
protected override void Down(MigrationBuilder migrationBuilder)
{
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,284 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
namespace ErsatzTV.Infrastructure.Migrations
{
public partial class Add_Artist : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
"ArtistMetadataId",
"Tag",
"INTEGER",
nullable: true);
migrationBuilder.AddColumn<int>(
"ArtistMetadataId",
"Studio",
"INTEGER",
nullable: true);
migrationBuilder.AddColumn<int>(
"ArtistId",
"MusicVideo",
"INTEGER",
nullable: true);
migrationBuilder.AddColumn<int>(
"ArtistMetadataId",
"Genre",
"INTEGER",
nullable: true);
migrationBuilder.AddColumn<int>(
"ArtistMetadataId",
"Artwork",
"INTEGER",
nullable: true);
migrationBuilder.CreateTable(
"Artist",
table => new
{
Id = table.Column<int>("INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true)
},
constraints: table =>
{
table.PrimaryKey("PK_Artist", x => x.Id);
table.ForeignKey(
"FK_Artist_MediaItem_Id",
x => x.Id,
"MediaItem",
"Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
"ArtistMetadata",
table => new
{
Id = table.Column<int>("INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
Disambiguation = table.Column<string>("TEXT", nullable: true),
Biography = table.Column<string>("TEXT", nullable: true),
Formed = table.Column<string>("TEXT", nullable: true),
ArtistId = table.Column<int>("INTEGER", nullable: true),
MetadataKind = table.Column<int>("INTEGER", nullable: false),
Title = table.Column<string>("TEXT", nullable: true),
OriginalTitle = table.Column<string>("TEXT", nullable: true),
SortTitle = table.Column<string>("TEXT", nullable: true),
Year = table.Column<int>("INTEGER", nullable: true),
ReleaseDate = table.Column<DateTime>("TEXT", nullable: true),
DateAdded = table.Column<DateTime>("TEXT", nullable: false),
DateUpdated = table.Column<DateTime>("TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_ArtistMetadata", x => x.Id);
table.ForeignKey(
"FK_ArtistMetadata_Artist_ArtistId",
x => x.ArtistId,
"Artist",
"Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
"Mood",
table => new
{
Id = table.Column<int>("INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
Name = table.Column<string>("TEXT", nullable: true),
ArtistMetadataId = table.Column<int>("INTEGER", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_Mood", x => x.Id);
table.ForeignKey(
"FK_Mood_ArtistMetadata_ArtistMetadataId",
x => x.ArtistMetadataId,
"ArtistMetadata",
"Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
"Style",
table => new
{
Id = table.Column<int>("INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
Name = table.Column<string>("TEXT", nullable: true),
ArtistMetadataId = table.Column<int>("INTEGER", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_Style", x => x.Id);
table.ForeignKey(
"FK_Style_ArtistMetadata_ArtistMetadataId",
x => x.ArtistMetadataId,
"ArtistMetadata",
"Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
"IX_Tag_ArtistMetadataId",
"Tag",
"ArtistMetadataId");
migrationBuilder.CreateIndex(
"IX_Studio_ArtistMetadataId",
"Studio",
"ArtistMetadataId");
migrationBuilder.CreateIndex(
"IX_MusicVideo_ArtistId",
"MusicVideo",
"ArtistId");
migrationBuilder.CreateIndex(
"IX_Genre_ArtistMetadataId",
"Genre",
"ArtistMetadataId");
migrationBuilder.CreateIndex(
"IX_Artwork_ArtistMetadataId",
"Artwork",
"ArtistMetadataId");
migrationBuilder.CreateIndex(
"IX_ArtistMetadata_ArtistId",
"ArtistMetadata",
"ArtistId");
migrationBuilder.CreateIndex(
"IX_Mood_ArtistMetadataId",
"Mood",
"ArtistMetadataId");
migrationBuilder.CreateIndex(
"IX_Style_ArtistMetadataId",
"Style",
"ArtistMetadataId");
migrationBuilder.AddForeignKey(
"FK_Artwork_ArtistMetadata_ArtistMetadataId",
"Artwork",
"ArtistMetadataId",
"ArtistMetadata",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
migrationBuilder.AddForeignKey(
"FK_Genre_ArtistMetadata_ArtistMetadataId",
"Genre",
"ArtistMetadataId",
"ArtistMetadata",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
migrationBuilder.AddForeignKey(
"FK_MusicVideo_Artist_ArtistId",
"MusicVideo",
"ArtistId",
"Artist",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
migrationBuilder.AddForeignKey(
"FK_Studio_ArtistMetadata_ArtistMetadataId",
"Studio",
"ArtistMetadataId",
"ArtistMetadata",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
migrationBuilder.AddForeignKey(
"FK_Tag_ArtistMetadata_ArtistMetadataId",
"Tag",
"ArtistMetadataId",
"ArtistMetadata",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
"FK_Artwork_ArtistMetadata_ArtistMetadataId",
"Artwork");
migrationBuilder.DropForeignKey(
"FK_Genre_ArtistMetadata_ArtistMetadataId",
"Genre");
migrationBuilder.DropForeignKey(
"FK_MusicVideo_Artist_ArtistId",
"MusicVideo");
migrationBuilder.DropForeignKey(
"FK_Studio_ArtistMetadata_ArtistMetadataId",
"Studio");
migrationBuilder.DropForeignKey(
"FK_Tag_ArtistMetadata_ArtistMetadataId",
"Tag");
migrationBuilder.DropTable(
"Mood");
migrationBuilder.DropTable(
"Style");
migrationBuilder.DropTable(
"ArtistMetadata");
migrationBuilder.DropTable(
"Artist");
migrationBuilder.DropIndex(
"IX_Tag_ArtistMetadataId",
"Tag");
migrationBuilder.DropIndex(
"IX_Studio_ArtistMetadataId",
"Studio");
migrationBuilder.DropIndex(
"IX_MusicVideo_ArtistId",
"MusicVideo");
migrationBuilder.DropIndex(
"IX_Genre_ArtistMetadataId",
"Genre");
migrationBuilder.DropIndex(
"IX_Artwork_ArtistMetadataId",
"Artwork");
migrationBuilder.DropColumn(
"ArtistMetadataId",
"Tag");
migrationBuilder.DropColumn(
"ArtistMetadataId",
"Studio");
migrationBuilder.DropColumn(
"ArtistId",
"MusicVideo");
migrationBuilder.DropColumn(
"ArtistMetadataId",
"Genre");
migrationBuilder.DropColumn(
"ArtistMetadataId",
"Artwork");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,21 @@
using Microsoft.EntityFrameworkCore.Migrations;
namespace ErsatzTV.Infrastructure.Migrations
{
public partial class Update_MusicVideoMetadata_Title : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql(
@"UPDATE MusicVideoMetadata SET DateUpdated = '0001-01-01 00:00:00' WHERE MetadataKind = 0");
migrationBuilder.Sql(
@"UPDATE LibraryPath SET LastScan = '0001-01-01 00:00:00' WHERE LibraryId IN
(SELECT Id FROM Library WHERE MediaKind = 3)");
}
protected override void Down(MigrationBuilder migrationBuilder)
{
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,42 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
namespace ErsatzTV.Infrastructure.Migrations
{
public partial class Add_ArtistMetadata : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
var now = DateTime.UtcNow.ToString("yyyy-MM-dd HH:mm:ss.FFFFFFF");
migrationBuilder.Sql(
@"INSERT INTO MediaItem (LibraryPathId)
SELECT LibraryPath.Id FROM LibraryPath INNER JOIN Library L on LibraryPath.LibraryId = L.Id
WHERE MediaKind = 3");
migrationBuilder.Sql(
@"INSERT INTO Artist (Id)
SELECT MediaItem.Id FROM MediaItem
INNER JOIN LibraryPath LP on MediaItem.LibraryPathId = LP.Id
INNER JOIN Library L on LP.LibraryId = L.Id
WHERE MediaKind = 3 AND NOT EXISTS (SELECT Id FROM MusicVideo WHERE Id = MediaItem.Id)");
migrationBuilder.Sql(
$@"INSERT INTO ArtistMetadata (ArtistId, Title, DateAdded, DateUpdated, MetadataKind)
SELECT Id, '[FAKE ARTIST]', '{now}', '{now}', 0 FROM Artist");
migrationBuilder.Sql(
@"UPDATE MusicVideo SET ArtistId =
(SELECT Artist.Id FROM Artist
INNER JOIN MediaItem MIA on Artist.Id = MIA.Id
INNER JOIN MediaItem MIMV on MusicVideo.Id = MIMV.Id
INNER JOIN LibraryPath LPA on MIA.LibraryPathId = LPA.Id
INNER JOIN LibraryPath LPMV on MIMV.LibraryPathId = LPMV.Id
WHERE LPA.LibraryId = LPMV.LibraryId)");
}
protected override void Down(MigrationBuilder migrationBuilder)
{
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,47 @@
using Microsoft.EntityFrameworkCore.Migrations;
namespace ErsatzTV.Infrastructure.Migrations
{
public partial class Update_ArtistMetadata_FK : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<int>(
"ArtistId",
"MusicVideo",
"INTEGER",
nullable: false,
oldClrType: typeof(int),
oldType: "INTEGER",
oldNullable: true);
migrationBuilder.AlterColumn<int>(
"ArtistId",
"ArtistMetadata",
"INTEGER",
nullable: false,
oldClrType: typeof(int),
oldType: "INTEGER",
oldNullable: true);
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<int>(
"ArtistId",
"MusicVideo",
"INTEGER",
nullable: true,
oldClrType: typeof(int),
oldType: "INTEGER");
migrationBuilder.AlterColumn<int>(
"ArtistId",
"ArtistMetadata",
"INTEGER",
nullable: true,
oldClrType: typeof(int),
oldType: "INTEGER");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,19 @@
using Microsoft.EntityFrameworkCore.Migrations;
namespace ErsatzTV.Infrastructure.Migrations
{
public partial class Delete_MusicVideoMetadata_Artist : Migration
{
protected override void Up(MigrationBuilder migrationBuilder) =>
migrationBuilder.DropColumn(
"Artist",
"MusicVideoMetadata");
protected override void Down(MigrationBuilder migrationBuilder) =>
migrationBuilder.AddColumn<string>(
"Artist",
"MusicVideoMetadata",
"TEXT",
nullable: true);
}
}
@@ -0,0 +1,16 @@
using Microsoft.EntityFrameworkCore.Migrations;
namespace ErsatzTV.Infrastructure.Migrations
{
public partial class Reset_LibraryPathLastScan_MusicVideos : Migration
{
protected override void Up(MigrationBuilder migrationBuilder) =>
migrationBuilder.Sql(
@"UPDATE LibraryPath SET LastScan = '0001-01-01 00:00:00' WHERE Id IN
(SELECT LP.Id FROM LibraryPath LP INNER JOIN Library L on L.Id = LP.LibraryId WHERE MediaKind = 3)");
protected override void Down(MigrationBuilder migrationBuilder)
{
}
}
}
@@ -16,6 +16,57 @@ namespace ErsatzTV.Infrastructure.Migrations
modelBuilder
.HasAnnotation("ProductVersion", "5.0.4");
modelBuilder.Entity(
"ErsatzTV.Core.Domain.ArtistMetadata",
b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("ArtistId")
.HasColumnType("INTEGER");
b.Property<string>("Biography")
.HasColumnType("TEXT");
b.Property<DateTime>("DateAdded")
.HasColumnType("TEXT");
b.Property<DateTime>("DateUpdated")
.HasColumnType("TEXT");
b.Property<string>("Disambiguation")
.HasColumnType("TEXT");
b.Property<string>("Formed")
.HasColumnType("TEXT");
b.Property<int>("MetadataKind")
.HasColumnType("INTEGER");
b.Property<string>("OriginalTitle")
.HasColumnType("TEXT");
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("ArtistId");
b.ToTable("ArtistMetadata");
});
modelBuilder.Entity(
"ErsatzTV.Core.Domain.Artwork",
b =>
@@ -24,6 +75,9 @@ namespace ErsatzTV.Infrastructure.Migrations
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int?>("ArtistMetadataId")
.HasColumnType("INTEGER");
b.Property<int>("ArtworkKind")
.HasColumnType("INTEGER");
@@ -56,6 +110,8 @@ namespace ErsatzTV.Infrastructure.Migrations
b.HasKey("Id");
b.HasIndex("ArtistMetadataId");
b.HasIndex("ChannelId");
b.HasIndex("EpisodeMetadataId");
@@ -293,6 +349,9 @@ namespace ErsatzTV.Infrastructure.Migrations
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int?>("ArtistMetadataId")
.HasColumnType("INTEGER");
b.Property<int?>("EpisodeMetadataId")
.HasColumnType("INTEGER");
@@ -313,6 +372,8 @@ namespace ErsatzTV.Infrastructure.Migrations
b.HasKey("Id");
b.HasIndex("ArtistMetadataId");
b.HasIndex("EpisodeMetadataId");
b.HasIndex("MovieMetadataId");
@@ -532,6 +593,27 @@ namespace ErsatzTV.Infrastructure.Migrations
b.ToTable("MediaVersion");
});
modelBuilder.Entity(
"ErsatzTV.Core.Domain.Mood",
b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int?>("ArtistMetadataId")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("ArtistMetadataId");
b.ToTable("Mood");
});
modelBuilder.Entity(
"ErsatzTV.Core.Domain.MovieMetadata",
b =>
@@ -594,9 +676,6 @@ namespace ErsatzTV.Infrastructure.Migrations
b.Property<string>("Album")
.HasColumnType("TEXT");
b.Property<string>("Artist")
.HasColumnType("TEXT");
b.Property<DateTime>("DateAdded")
.HasColumnType("TEXT");
@@ -967,6 +1046,9 @@ namespace ErsatzTV.Infrastructure.Migrations
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int?>("ArtistMetadataId")
.HasColumnType("INTEGER");
b.Property<int?>("EpisodeMetadataId")
.HasColumnType("INTEGER");
@@ -987,6 +1069,8 @@ namespace ErsatzTV.Infrastructure.Migrations
b.HasKey("Id");
b.HasIndex("ArtistMetadataId");
b.HasIndex("EpisodeMetadataId");
b.HasIndex("MovieMetadataId");
@@ -1000,6 +1084,27 @@ namespace ErsatzTV.Infrastructure.Migrations
b.ToTable("Studio");
});
modelBuilder.Entity(
"ErsatzTV.Core.Domain.Style",
b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int?>("ArtistMetadataId")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("ArtistMetadataId");
b.ToTable("Style");
});
modelBuilder.Entity(
"ErsatzTV.Core.Domain.Tag",
b =>
@@ -1008,6 +1113,9 @@ namespace ErsatzTV.Infrastructure.Migrations
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int?>("ArtistMetadataId")
.HasColumnType("INTEGER");
b.Property<int?>("EpisodeMetadataId")
.HasColumnType("INTEGER");
@@ -1028,6 +1136,8 @@ namespace ErsatzTV.Infrastructure.Migrations
b.HasKey("Id");
b.HasIndex("ArtistMetadataId");
b.HasIndex("EpisodeMetadataId");
b.HasIndex("MovieMetadataId");
@@ -1080,6 +1190,15 @@ namespace ErsatzTV.Infrastructure.Migrations
b.ToTable("PlexMediaFile");
});
modelBuilder.Entity(
"ErsatzTV.Core.Domain.Artist",
b =>
{
b.HasBaseType("ErsatzTV.Core.Domain.MediaItem");
b.ToTable("Artist");
});
modelBuilder.Entity(
"ErsatzTV.Core.Domain.Episode",
b =>
@@ -1112,6 +1231,11 @@ namespace ErsatzTV.Infrastructure.Migrations
{
b.HasBaseType("ErsatzTV.Core.Domain.MediaItem");
b.Property<int>("ArtistId")
.HasColumnType("INTEGER");
b.HasIndex("ArtistId");
b.ToTable("MusicVideo");
});
@@ -1267,10 +1391,28 @@ namespace ErsatzTV.Infrastructure.Migrations
b.ToTable("PlexShow");
});
modelBuilder.Entity(
"ErsatzTV.Core.Domain.ArtistMetadata",
b =>
{
b.HasOne("ErsatzTV.Core.Domain.Artist", "Artist")
.WithMany("ArtistMetadata")
.HasForeignKey("ArtistId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Artist");
});
modelBuilder.Entity(
"ErsatzTV.Core.Domain.Artwork",
b =>
{
b.HasOne("ErsatzTV.Core.Domain.ArtistMetadata", null)
.WithMany("Artwork")
.HasForeignKey("ArtistMetadataId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("ErsatzTV.Core.Domain.Channel", null)
.WithMany("Artwork")
.HasForeignKey("ChannelId")
@@ -1366,6 +1508,11 @@ namespace ErsatzTV.Infrastructure.Migrations
"ErsatzTV.Core.Domain.Genre",
b =>
{
b.HasOne("ErsatzTV.Core.Domain.ArtistMetadata", null)
.WithMany("Genres")
.HasForeignKey("ArtistMetadataId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("ErsatzTV.Core.Domain.EpisodeMetadata", null)
.WithMany("Genres")
.HasForeignKey("EpisodeMetadataId");
@@ -1475,6 +1622,16 @@ namespace ErsatzTV.Infrastructure.Migrations
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity(
"ErsatzTV.Core.Domain.Mood",
b =>
{
b.HasOne("ErsatzTV.Core.Domain.ArtistMetadata", null)
.WithMany("Moods")
.HasForeignKey("ArtistMetadataId")
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity(
"ErsatzTV.Core.Domain.MovieMetadata",
b =>
@@ -1725,6 +1882,10 @@ namespace ErsatzTV.Infrastructure.Migrations
"ErsatzTV.Core.Domain.Studio",
b =>
{
b.HasOne("ErsatzTV.Core.Domain.ArtistMetadata", null)
.WithMany("Studios")
.HasForeignKey("ArtistMetadataId");
b.HasOne("ErsatzTV.Core.Domain.EpisodeMetadata", null)
.WithMany("Studios")
.HasForeignKey("EpisodeMetadataId");
@@ -1749,10 +1910,24 @@ namespace ErsatzTV.Infrastructure.Migrations
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity(
"ErsatzTV.Core.Domain.Style",
b =>
{
b.HasOne("ErsatzTV.Core.Domain.ArtistMetadata", null)
.WithMany("Styles")
.HasForeignKey("ArtistMetadataId")
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity(
"ErsatzTV.Core.Domain.Tag",
b =>
{
b.HasOne("ErsatzTV.Core.Domain.ArtistMetadata", null)
.WithMany("Tags")
.HasForeignKey("ArtistMetadataId");
b.HasOne("ErsatzTV.Core.Domain.EpisodeMetadata", null)
.WithMany("Tags")
.HasForeignKey("EpisodeMetadataId");
@@ -1810,6 +1985,17 @@ namespace ErsatzTV.Infrastructure.Migrations
.IsRequired();
});
modelBuilder.Entity(
"ErsatzTV.Core.Domain.Artist",
b =>
{
b.HasOne("ErsatzTV.Core.Domain.MediaItem", null)
.WithOne()
.HasForeignKey("ErsatzTV.Core.Domain.Artist", "Id")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity(
"ErsatzTV.Core.Domain.Episode",
b =>
@@ -1844,11 +2030,19 @@ namespace ErsatzTV.Infrastructure.Migrations
"ErsatzTV.Core.Domain.MusicVideo",
b =>
{
b.HasOne("ErsatzTV.Core.Domain.Artist", "Artist")
.WithMany("MusicVideos")
.HasForeignKey("ArtistId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("ErsatzTV.Core.Domain.MediaItem", null)
.WithOne()
.HasForeignKey("ErsatzTV.Core.Domain.MusicVideo", "Id")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Artist");
});
modelBuilder.Entity(
@@ -1991,6 +2185,23 @@ namespace ErsatzTV.Infrastructure.Migrations
.IsRequired();
});
modelBuilder.Entity(
"ErsatzTV.Core.Domain.ArtistMetadata",
b =>
{
b.Navigation("Artwork");
b.Navigation("Genres");
b.Navigation("Moods");
b.Navigation("Studios");
b.Navigation("Styles");
b.Navigation("Tags");
});
modelBuilder.Entity(
"ErsatzTV.Core.Domain.Channel",
b =>
@@ -2102,6 +2313,15 @@ namespace ErsatzTV.Infrastructure.Migrations
b.Navigation("Tags");
});
modelBuilder.Entity(
"ErsatzTV.Core.Domain.Artist",
b =>
{
b.Navigation("ArtistMetadata");
b.Navigation("MusicVideos");
});
modelBuilder.Entity(
"ErsatzTV.Core.Domain.Episode",
b =>
+141 -65
View File
@@ -23,13 +23,12 @@ using Query = Lucene.Net.Search.Query;
namespace ErsatzTV.Infrastructure.Search
{
public class SearchIndex : ISearchIndex
public sealed class SearchIndex : ISearchIndex
{
private const LuceneVersion AppLuceneVersion = LuceneVersion.LUCENE_48;
private const string IdField = "id";
private const string TypeField = "type";
private const string ArtistField = "artist";
private const string TitleField = "title";
private const string SortTitleField = "sort_title";
private const string GenreField = "genre";
@@ -40,111 +39,99 @@ namespace ErsatzTV.Infrastructure.Search
private const string JumpLetterField = "jump_letter";
private const string ReleaseDateField = "release_date";
private const string StudioField = "studio";
private const string LanguageField = "language";
private const string StyleField = "style";
private const string MoodField = "mood";
private const string MovieType = "movie";
private const string ShowType = "show";
private const string ArtistType = "artist";
private const string MusicVideoType = "music_video";
private static bool _isRebuilding;
private readonly ILocalFileSystem _localFileSystem;
private readonly ILogger<SearchIndex> _logger;
private readonly ISearchRepository _searchRepository;
private FSDirectory _directory;
private IndexWriter _writer;
public SearchIndex(
ILocalFileSystem localFileSystem,
ISearchRepository searchRepository,
ILogger<SearchIndex> logger)
public SearchIndex(ILogger<SearchIndex> logger) => _logger = logger;
public int Version => 6;
public Task<bool> Initialize(ILocalFileSystem localFileSystem)
{
_localFileSystem = localFileSystem;
_searchRepository = searchRepository;
_logger = logger;
}
localFileSystem.EnsureFolderExists(FileSystemLayout.SearchIndexFolder);
public int Version => 2;
_directory = FSDirectory.Open(FileSystemLayout.SearchIndexFolder);
var analyzer = new StandardAnalyzer(AppLuceneVersion);
var indexConfig = new IndexWriterConfig(AppLuceneVersion, analyzer)
{ OpenMode = OpenMode.CREATE_OR_APPEND };
_writer = new IndexWriter(_directory, indexConfig);
public Task<bool> Initialize()
{
_localFileSystem.EnsureFolderExists(FileSystemLayout.SearchIndexFolder);
return Task.FromResult(true);
}
public async Task<Unit> Rebuild(List<int> itemIds)
public async Task<Unit> Rebuild(ISearchRepository searchRepository, List<int> itemIds)
{
_isRebuilding = true;
await Initialize();
using var dir = FSDirectory.Open(FileSystemLayout.SearchIndexFolder);
var analyzer = new StandardAnalyzer(AppLuceneVersion);
var indexConfig = new IndexWriterConfig(AppLuceneVersion, analyzer) { OpenMode = OpenMode.CREATE };
using var writer = new IndexWriter(dir, indexConfig);
foreach (int id in itemIds)
{
Option<MediaItem> maybeMediaItem = await _searchRepository.GetItemToIndex(id);
Option<MediaItem> maybeMediaItem = await searchRepository.GetItemToIndex(id);
if (maybeMediaItem.IsSome)
{
MediaItem mediaItem = maybeMediaItem.ValueUnsafe();
switch (mediaItem)
{
case Movie movie:
UpdateMovie(movie, writer);
UpdateMovie(movie);
break;
case Show show:
UpdateShow(show, writer);
await UpdateShow(searchRepository, show);
break;
case Artist artist:
await UpdateArtist(searchRepository, artist);
break;
case MusicVideo musicVideo:
UpdateMusicVideo(musicVideo, writer);
UpdateMusicVideo(musicVideo);
break;
}
}
}
_isRebuilding = false;
_writer.Commit();
return Unit.Default;
}
public Task<Unit> AddItems(List<MediaItem> items) => UpdateItems(items);
public Task<Unit> AddItems(ISearchRepository searchRepository, List<MediaItem> items) =>
UpdateItems(searchRepository, items);
public Task<Unit> UpdateItems(List<MediaItem> items)
public async Task<Unit> UpdateItems(ISearchRepository searchRepository, List<MediaItem> items)
{
using var dir = FSDirectory.Open(FileSystemLayout.SearchIndexFolder);
var analyzer = new StandardAnalyzer(AppLuceneVersion);
var indexConfig = new IndexWriterConfig(AppLuceneVersion, analyzer) { OpenMode = OpenMode.APPEND };
using var writer = new IndexWriter(dir, indexConfig);
foreach (MediaItem item in items)
{
switch (item)
{
case Movie movie:
UpdateMovie(movie, writer);
UpdateMovie(movie);
break;
case Show show:
UpdateShow(show, writer);
await UpdateShow(searchRepository, show);
break;
case Artist artist:
await UpdateArtist(searchRepository, artist);
break;
case MusicVideo musicVideo:
UpdateMusicVideo(musicVideo, writer);
UpdateMusicVideo(musicVideo);
break;
}
}
return Task.FromResult(Unit.Default);
return Unit.Default;
}
public Task<Unit> RemoveItems(List<int> ids)
{
using var dir = FSDirectory.Open(FileSystemLayout.SearchIndexFolder);
var analyzer = new StandardAnalyzer(AppLuceneVersion);
var indexConfig = new IndexWriterConfig(AppLuceneVersion, analyzer) { OpenMode = OpenMode.APPEND };
using var writer = new IndexWriter(dir, indexConfig);
foreach (int id in ids)
{
writer.DeleteDocuments(new Term(IdField, id.ToString()));
_writer.DeleteDocuments(new Term(IdField, id.ToString()));
}
return Task.FromResult(Unit.Default);
@@ -152,16 +139,14 @@ namespace ErsatzTV.Infrastructure.Search
public Task<SearchResult> Search(string searchQuery, int skip, int limit, string searchField = "")
{
if (_isRebuilding ||
string.IsNullOrWhiteSpace(searchQuery.Replace("*", string.Empty).Replace("?", string.Empty)))
if (string.IsNullOrWhiteSpace(searchQuery.Replace("*", string.Empty).Replace("?", string.Empty)))
{
return new SearchResult(new List<SearchItem>(), 0).AsTask();
}
using var dir = FSDirectory.Open(FileSystemLayout.SearchIndexFolder);
using var reader = DirectoryReader.Open(dir);
using DirectoryReader reader = _writer.GetReader(true);
var searcher = new IndexSearcher(reader);
int hitsLimit = skip + limit;
int hitsLimit = limit == 0 ? searcher.IndexReader.MaxDoc : skip + limit;
using var analyzer = new StandardAnalyzer(AppLuceneVersion);
QueryParser parser = !string.IsNullOrWhiteSpace(searchField)
? new QueryParser(AppLuceneVersion, searchField, analyzer)
@@ -171,17 +156,33 @@ namespace ErsatzTV.Infrastructure.Search
var filter = new DuplicateFilter(TitleAndYearField);
var sort = new Sort(new SortField(SortTitleField, SortFieldType.STRING));
TopFieldDocs topDocs = searcher.Search(query, filter, hitsLimit, sort, true, true);
IEnumerable<ScoreDoc> selectedHits = topDocs.ScoreDocs.Skip(skip).Take(limit);
IEnumerable<ScoreDoc> selectedHits = topDocs.ScoreDocs.Skip(skip);
if (limit > 0)
{
selectedHits = selectedHits.Take(limit);
}
var searchResult = new SearchResult(
selectedHits.Map(d => ProjectToSearchItem(searcher.Doc(d.Doc))).ToList(),
topDocs.TotalHits);
searchResult.PageMap = GetSearchPageMap(searcher, query, filter, sort, limit);
if (limit > 0)
{
searchResult.PageMap = GetSearchPageMap(searcher, query, filter, sort, limit);
}
return searchResult.AsTask();
}
public void Commit() => _writer.Commit();
public void Dispose()
{
_writer?.Dispose();
_directory?.Dispose();
}
private static Option<SearchPageMap> GetSearchPageMap(
IndexSearcher searcher,
Query query,
@@ -228,7 +229,7 @@ namespace ErsatzTV.Infrastructure.Search
return new SearchPageMap(map);
}
private void UpdateMovie(Movie movie, IndexWriter writer)
private void UpdateMovie(Movie movie)
{
Option<MovieMetadata> maybeMetadata = movie.MovieMetadata.HeadOrNone();
if (maybeMetadata.IsSome)
@@ -248,6 +249,8 @@ namespace ErsatzTV.Infrastructure.Search
new StringField(JumpLetterField, GetJumpLetter(metadata), Field.Store.YES)
};
AddLanguages(doc, movie.MediaVersions);
if (metadata.ReleaseDate.HasValue)
{
doc.Add(
@@ -277,7 +280,7 @@ namespace ErsatzTV.Infrastructure.Search
doc.Add(new TextField(StudioField, studio.Name, Field.Store.NO));
}
writer.UpdateDocument(new Term(IdField, movie.Id.ToString()), doc);
_writer.UpdateDocument(new Term(IdField, movie.Id.ToString()), doc);
}
catch (Exception ex)
{
@@ -287,7 +290,22 @@ namespace ErsatzTV.Infrastructure.Search
}
}
private void UpdateShow(Show show, IndexWriter writer)
private void AddLanguages(Document doc, List<MediaVersion> mediaVersions)
{
Option<MediaVersion> maybeVersion = mediaVersions.HeadOrNone();
if (maybeVersion.IsSome)
{
MediaVersion version = maybeVersion.ValueUnsafe();
foreach (string lang in version.Streams.Filter(ms => ms.MediaStreamKind == MediaStreamKind.Video)
.Map(ms => ms.Language).Distinct()
.Filter(s => !string.IsNullOrWhiteSpace(s)))
{
doc.Add(new StringField(LanguageField, lang, Field.Store.NO));
}
}
}
private async Task UpdateShow(ISearchRepository searchRepository, Show show)
{
Option<ShowMetadata> maybeMetadata = show.ShowMetadata.HeadOrNone();
if (maybeMetadata.IsSome)
@@ -307,6 +325,12 @@ namespace ErsatzTV.Infrastructure.Search
new StringField(JumpLetterField, GetJumpLetter(metadata), Field.Store.YES)
};
List<string> languages = await searchRepository.GetLanguagesForShow(show);
foreach (string lang in languages.Distinct().Filter(s => !string.IsNullOrWhiteSpace(s)))
{
doc.Add(new StringField(LanguageField, lang, Field.Store.NO));
}
if (metadata.ReleaseDate.HasValue)
{
doc.Add(
@@ -336,7 +360,7 @@ namespace ErsatzTV.Infrastructure.Search
doc.Add(new TextField(StudioField, studio.Name, Field.Store.NO));
}
writer.UpdateDocument(new Term(IdField, show.Id.ToString()), doc);
_writer.UpdateDocument(new Term(IdField, show.Id.ToString()), doc);
}
catch (Exception ex)
{
@@ -346,7 +370,58 @@ namespace ErsatzTV.Infrastructure.Search
}
}
private void UpdateMusicVideo(MusicVideo musicVideo, IndexWriter writer)
private async Task UpdateArtist(ISearchRepository searchRepository, Artist artist)
{
Option<ArtistMetadata> maybeMetadata = artist.ArtistMetadata.HeadOrNone();
if (maybeMetadata.IsSome)
{
ArtistMetadata metadata = maybeMetadata.ValueUnsafe();
try
{
var doc = new Document
{
new StringField(IdField, artist.Id.ToString(), Field.Store.YES),
new StringField(TypeField, ArtistType, Field.Store.NO),
new TextField(TitleField, metadata.Title, Field.Store.NO),
new StringField(SortTitleField, metadata.SortTitle.ToLowerInvariant(), Field.Store.NO),
new TextField(LibraryNameField, artist.LibraryPath.Library.Name, Field.Store.NO),
new StringField(TitleAndYearField, GetTitleAndYear(metadata), Field.Store.NO),
new StringField(JumpLetterField, GetJumpLetter(metadata), Field.Store.YES)
};
List<string> languages = await searchRepository.GetLanguagesForArtist(artist);
foreach (string lang in languages.Distinct().Filter(s => !string.IsNullOrWhiteSpace(s)))
{
doc.Add(new StringField(LanguageField, lang, Field.Store.NO));
}
foreach (Genre genre in metadata.Genres)
{
doc.Add(new TextField(GenreField, genre.Name, Field.Store.NO));
}
foreach (Style style in metadata.Styles)
{
doc.Add(new TextField(StyleField, style.Name, Field.Store.NO));
}
foreach (Mood mood in metadata.Moods)
{
doc.Add(new TextField(MoodField, mood.Name, Field.Store.NO));
}
_writer.UpdateDocument(new Term(IdField, artist.Id.ToString()), doc);
}
catch (Exception ex)
{
metadata.Artist = null;
_logger.LogWarning(ex, "Error indexing show with metadata {@Metadata}", metadata);
}
}
}
private void UpdateMusicVideo(MusicVideo musicVideo)
{
Option<MusicVideoMetadata> maybeMetadata = musicVideo.MusicVideoMetadata.HeadOrNone();
if (maybeMetadata.IsSome)
@@ -359,7 +434,6 @@ namespace ErsatzTV.Infrastructure.Search
{
new StringField(IdField, musicVideo.Id.ToString(), Field.Store.YES),
new StringField(TypeField, MusicVideoType, Field.Store.NO),
new TextField(ArtistField, metadata.Artist, Field.Store.NO),
new TextField(TitleField, metadata.Title, Field.Store.NO),
new StringField(SortTitleField, metadata.SortTitle.ToLowerInvariant(), Field.Store.NO),
new TextField(LibraryNameField, musicVideo.LibraryPath.Library.Name, Field.Store.NO),
@@ -367,6 +441,8 @@ namespace ErsatzTV.Infrastructure.Search
new StringField(JumpLetterField, GetJumpLetter(metadata), Field.Store.YES)
};
AddLanguages(doc, musicVideo.MediaVersions);
if (metadata.ReleaseDate.HasValue)
{
doc.Add(
@@ -396,7 +472,7 @@ namespace ErsatzTV.Infrastructure.Search
doc.Add(new TextField(StudioField, studio.Name, Field.Store.NO));
}
writer.UpdateDocument(new Term(IdField, musicVideo.Id.ToString()), doc);
_writer.UpdateDocument(new Term(IdField, musicVideo.Id.ToString()), doc);
}
catch (Exception ex)
{
+6 -2
View File
@@ -1,7 +1,9 @@
using System.Threading.Tasks;
using ErsatzTV.Application.Channels.Queries;
using ErsatzTV.Application.HDHR.Queries;
using ErsatzTV.Core.Hdhr;
using ErsatzTV.Extensions;
using LanguageExt;
using MediatR;
using Microsoft.AspNetCore.Mvc;
@@ -20,8 +22,10 @@ namespace ErsatzTV.Controllers
new OkObjectResult(new DeviceXml(Request.Scheme, Request.Host.ToString()));
[HttpGet("discover.json")]
public IActionResult Discover() =>
new OkObjectResult(new Discover(Request.Scheme, Request.Host.ToString(), 2));
[ResponseCache(NoStore = true)]
public Task<IActionResult> Discover() =>
_mediator.Send(new GetHDHRTunerCount()).Map<int, IActionResult>(
tunerCount => new OkObjectResult(new Discover(Request.Scheme, Request.Host.ToString(), tunerCount)));
[HttpGet("lineup_status.json")]
public IActionResult LineupStatus() =>

Some files were not shown because too many files have changed in this diff Show More