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
Jason DoveandGitHub efae005447 use full preferred language names in ui (#137) 2021-04-04 18:30:42 -05:00
Jason DoveandGitHub cead787c55 force SAR 1:1 if missing (#136) 2021-04-04 18:00:39 -05:00
Jason DoveandGitHub 77a69af1a8 sort channels and schedules in playout editor (#135) 2021-04-04 16:24:46 -05:00
Jason DoveandGitHub 8fea24a3a5 add fallback metadata for music videos (#134) 2021-04-04 15:57:25 -05:00
Jason DoveandGitHub 6b44873474 add library scan progress detail (#133)
* add library scan progress detail

* scan plex libraries on plex thread
2021-04-04 10:44:10 -05:00
203 changed files with 25122 additions and 885 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);
}
}
@@ -50,7 +50,7 @@ namespace ErsatzTV.Application.FFmpegProfiles.Commands
AudioCodec = request.AudioCodec,
AudioBitrate = request.AudioBitrate,
AudioBufferSize = request.AudioBufferSize,
NormalizeLoudness= request.NormalizeLoudness,
NormalizeLoudness = request.NormalizeLoudness,
AudioChannels = request.AudioChannels,
AudioSampleRate = request.AudioSampleRate,
NormalizeAudio = request.NormalizeAudio,
@@ -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);
}
}
}
@@ -0,0 +1,8 @@
using System.Collections.Generic;
using System.Globalization;
using MediatR;
namespace ErsatzTV.Application.MediaItems.Queries
{
public record GetAllLanguageCodes : IRequest<List<CultureInfo>>;
}
@@ -0,0 +1,35 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Core.Interfaces.Repositories;
using LanguageExt;
using MediatR;
namespace ErsatzTV.Application.MediaItems.Queries
{
public class GetAllLanguageCodesHandler : IRequestHandler<GetAllLanguageCodes, List<CultureInfo>>
{
private readonly IMediaItemRepository _mediaItemRepository;
public GetAllLanguageCodesHandler(IMediaItemRepository mediaItemRepository) =>
_mediaItemRepository = mediaItemRepository;
public async Task<List<CultureInfo>> Handle(GetAllLanguageCodes request, CancellationToken cancellationToken)
{
var result = new List<CultureInfo>();
CultureInfo[] allCultures = CultureInfo.GetCultures(CultureTypes.AllCultures);
List<string> allLanguageCodes = await _mediaItemRepository.GetAllLanguageCodes();
foreach (string code in allLanguageCodes)
{
Option<CultureInfo> maybeCulture = allCultures.Find(
ci => string.Equals(code, ci.ThreeLetterISOLanguageName, StringComparison.OrdinalIgnoreCase));
await maybeCulture.IfSomeAsync(cultureInfo => result.Add(cultureInfo));
}
return result;
}
}
}
@@ -8,24 +8,15 @@ namespace ErsatzTV.Application.MediaSources.Commands
{
int LibraryId { get; }
bool ForceScan { get; }
bool Rescan { get; }
}
public record ScanLocalLibraryIfNeeded(int LibraryId) : IScanLocalLibrary
{
public bool ForceScan => false;
public bool Rescan => false;
}
public record ForceScanLocalLibrary(int LibraryId) : IScanLocalLibrary
{
public bool ForceScan => true;
public bool Rescan => false;
}
public record ForceRescanLocalLibrary(int LibraryId) : IScanLocalLibrary
{
public bool ForceScan => true;
public bool Rescan => true;
}
}
@@ -8,6 +8,7 @@ using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Locking;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Metadata;
using LanguageExt;
using MediatR;
using Microsoft.Extensions.Logging;
@@ -16,13 +17,13 @@ using Unit = LanguageExt.Unit;
namespace ErsatzTV.Application.MediaSources.Commands
{
public class ScanLocalLibraryHandler : IRequestHandler<ForceScanLocalLibrary, Either<BaseError, string>>,
IRequestHandler<ScanLocalLibraryIfNeeded, Either<BaseError, string>>,
IRequestHandler<ForceRescanLocalLibrary, Either<BaseError, string>>
IRequestHandler<ScanLocalLibraryIfNeeded, Either<BaseError, string>>
{
private readonly IConfigElementRepository _configElementRepository;
private readonly IEntityLocker _entityLocker;
private readonly ILibraryRepository _libraryRepository;
private readonly ILogger<ScanLocalLibraryHandler> _logger;
private readonly IMediator _mediator;
private readonly IMovieFolderScanner _movieFolderScanner;
private readonly IMusicVideoFolderScanner _musicVideoFolderScanner;
private readonly ITelevisionFolderScanner _televisionFolderScanner;
@@ -34,6 +35,7 @@ namespace ErsatzTV.Application.MediaSources.Commands
ITelevisionFolderScanner televisionFolderScanner,
IMusicVideoFolderScanner musicVideoFolderScanner,
IEntityLocker entityLocker,
IMediator mediator,
ILogger<ScanLocalLibraryHandler> logger)
{
_libraryRepository = libraryRepository;
@@ -42,13 +44,10 @@ namespace ErsatzTV.Application.MediaSources.Commands
_televisionFolderScanner = televisionFolderScanner;
_musicVideoFolderScanner = musicVideoFolderScanner;
_entityLocker = entityLocker;
_mediator = mediator;
_logger = logger;
}
public Task<Either<BaseError, string>> Handle(
ForceRescanLocalLibrary request,
CancellationToken cancellationToken) => Handle(request);
public Task<Either<BaseError, string>> Handle(
ForceScanLocalLibrary request,
CancellationToken cancellationToken) => Handle(request);
@@ -65,47 +64,63 @@ namespace ErsatzTV.Application.MediaSources.Commands
private async Task<Unit> PerformScan(RequestParameters parameters)
{
(LocalLibrary localLibrary, string ffprobePath, bool forceScan, bool rescan) = parameters;
(LocalLibrary localLibrary, string ffprobePath, bool forceScan) = parameters;
var lastScan = new DateTimeOffset(localLibrary.LastScan ?? DateTime.MinValue, TimeSpan.Zero);
if (forceScan || lastScan < DateTimeOffset.Now - TimeSpan.FromHours(6))
var sw = new Stopwatch();
sw.Start();
for (var i = 0; i < localLibrary.Paths.Count; i++)
{
var sw = new Stopwatch();
sw.Start();
LibraryPath libraryPath = localLibrary.Paths[i];
DateTimeOffset effectiveLastScan = rescan ? DateTimeOffset.MinValue : lastScan;
decimal progressMin = (decimal) i / localLibrary.Paths.Count;
decimal progressMax = (decimal) (i + 1) / localLibrary.Paths.Count;
foreach (LibraryPath libraryPath in localLibrary.Paths)
var lastScan = new DateTimeOffset(libraryPath.LastScan ?? DateTime.MinValue, TimeSpan.Zero);
if (forceScan || lastScan < DateTimeOffset.Now - TimeSpan.FromHours(6))
{
switch (localLibrary.MediaKind)
{
case LibraryMediaKind.Movies:
await _movieFolderScanner.ScanFolder(libraryPath, ffprobePath, effectiveLastScan);
await _movieFolderScanner.ScanFolder(
libraryPath,
ffprobePath,
lastScan,
progressMin,
progressMax);
break;
case LibraryMediaKind.Shows:
await _televisionFolderScanner.ScanFolder(libraryPath, ffprobePath, effectiveLastScan);
await _televisionFolderScanner.ScanFolder(
libraryPath,
ffprobePath,
lastScan,
progressMin,
progressMax);
break;
case LibraryMediaKind.MusicVideos:
await _musicVideoFolderScanner.ScanFolder(libraryPath, ffprobePath, effectiveLastScan);
await _musicVideoFolderScanner.ScanFolder(
libraryPath,
ffprobePath,
lastScan,
progressMin,
progressMax);
break;
}
libraryPath.LastScan = DateTime.UtcNow;
await _libraryRepository.UpdateLastScan(libraryPath);
}
localLibrary.LastScan = DateTime.UtcNow;
await _libraryRepository.UpdateLastScan(localLibrary);
await _mediator.Publish(new LibraryScanProgress(libraryPath.LibraryId, progressMax));
}
sw.Stop();
_logger.LogDebug(
"Scan of library {Name} completed in {Duration}",
localLibrary.Name,
TimeSpan.FromMilliseconds(sw.ElapsedMilliseconds));
}
else
{
_logger.LogDebug(
"Skipping unforced scan of library {Name}",
localLibrary.Name);
}
sw.Stop();
_logger.LogDebug(
"Scan of library {Name} completed in {Duration}",
localLibrary.Name,
TimeSpan.FromMilliseconds(sw.ElapsedMilliseconds));
await _mediator.Publish(new LibraryScanProgress(localLibrary.Id, 0));
_entityLocker.UnlockLibrary(localLibrary.Id);
return Unit.Default;
@@ -117,8 +132,7 @@ namespace ErsatzTV.Application.MediaSources.Commands
(library, ffprobePath) => new RequestParameters(
library,
ffprobePath,
request.ForceScan,
request.Rescan));
request.ForceScan));
private Task<Validation<BaseError, LocalLibrary>> LocalLibraryMustExist(
IScanLocalLibrary request) =>
@@ -133,6 +147,6 @@ namespace ErsatzTV.Application.MediaSources.Commands
ffprobePath =>
ffprobePath.ToValidation<BaseError>("FFprobe path does not exist on the file system"));
private record RequestParameters(LocalLibrary LocalLibrary, string FFprobePath, bool ForceScan, bool Rescan);
private record RequestParameters(LocalLibrary LocalLibrary, string FFprobePath, bool ForceScan);
}
}
+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;
@@ -4,7 +4,7 @@ using MediatR;
namespace ErsatzTV.Application.Plex.Commands
{
public interface ISynchronizePlexLibraryById : IRequest<Either<BaseError, string>>, IBackgroundServiceRequest
public interface ISynchronizePlexLibraryById : IRequest<Either<BaseError, string>>, IPlexBackgroundServiceRequest
{
int PlexLibraryId { get; }
bool ForceScan { get; }
@@ -20,6 +20,7 @@ namespace ErsatzTV.Application.Plex.Commands
IRequestHandler<SynchronizePlexLibraryByIdIfNeeded, Either<BaseError, string>>
{
private readonly IEntityLocker _entityLocker;
private readonly ILibraryRepository _libraryRepository;
private readonly ILogger<SynchronizePlexLibraryByIdHandler> _logger;
private readonly IMediaSourceRepository _mediaSourceRepository;
private readonly IPlexMovieLibraryScanner _plexMovieLibraryScanner;
@@ -31,6 +32,7 @@ namespace ErsatzTV.Application.Plex.Commands
IPlexSecretStore plexSecretStore,
IPlexMovieLibraryScanner plexMovieLibraryScanner,
IPlexTelevisionLibraryScanner plexTelevisionLibraryScanner,
ILibraryRepository libraryRepository,
IEntityLocker entityLocker,
ILogger<SynchronizePlexLibraryByIdHandler> logger)
{
@@ -38,6 +40,7 @@ namespace ErsatzTV.Application.Plex.Commands
_plexSecretStore = plexSecretStore;
_plexMovieLibraryScanner = plexMovieLibraryScanner;
_plexTelevisionLibraryScanner = plexTelevisionLibraryScanner;
_libraryRepository = libraryRepository;
_entityLocker = entityLocker;
_logger = logger;
}
@@ -78,7 +81,7 @@ namespace ErsatzTV.Application.Plex.Commands
}
parameters.Library.LastScan = DateTime.UtcNow;
await _mediaSourceRepository.Update(parameters.Library);
await _libraryRepository.UpdateLastScan(parameters.Library);
}
else
{
@@ -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();
@@ -14,10 +14,12 @@ using ErsatzTV.Core.Metadata;
using ErsatzTV.Core.Tests.Fakes;
using FluentAssertions;
using LanguageExt;
using MediatR;
using Microsoft.Extensions.Logging;
using Moq;
using NUnit.Framework;
using static LanguageExt.Prelude;
using Unit = LanguageExt.Unit;
namespace ErsatzTV.Core.Tests.Metadata
{
@@ -84,7 +86,9 @@ namespace ErsatzTV.Core.Tests.Metadata
Either<BaseError, Unit> result = await service.ScanFolder(
libraryPath,
FFprobePath,
DateTimeOffset.MinValue);
DateTimeOffset.MinValue,
0,
1);
result.IsLeft.Should().BeTrue();
result.IfLeft(error => error.Should().BeOfType<MediaSourceInaccessible>());
@@ -107,7 +111,9 @@ namespace ErsatzTV.Core.Tests.Metadata
Either<BaseError, Unit> result = await service.ScanFolder(
libraryPath,
FFprobePath,
DateTimeOffset.MinValue);
DateTimeOffset.MinValue,
0,
1);
result.IsRight.Should().BeTrue();
@@ -146,7 +152,9 @@ namespace ErsatzTV.Core.Tests.Metadata
Either<BaseError, Unit> result = await service.ScanFolder(
libraryPath,
FFprobePath,
DateTimeOffset.MinValue);
DateTimeOffset.MinValue,
0,
1);
result.IsRight.Should().BeTrue();
@@ -186,7 +194,9 @@ namespace ErsatzTV.Core.Tests.Metadata
Either<BaseError, Unit> result = await service.ScanFolder(
libraryPath,
FFprobePath,
DateTimeOffset.MinValue);
DateTimeOffset.MinValue,
0,
1);
result.IsRight.Should().BeTrue();
@@ -230,7 +240,9 @@ namespace ErsatzTV.Core.Tests.Metadata
Either<BaseError, Unit> result = await service.ScanFolder(
libraryPath,
FFprobePath,
DateTimeOffset.MinValue);
DateTimeOffset.MinValue,
0,
1);
result.IsRight.Should().BeTrue();
@@ -277,7 +289,9 @@ namespace ErsatzTV.Core.Tests.Metadata
Either<BaseError, Unit> result = await service.ScanFolder(
libraryPath,
FFprobePath,
DateTimeOffset.MinValue);
DateTimeOffset.MinValue,
0,
1);
result.IsRight.Should().BeTrue();
@@ -324,7 +338,9 @@ namespace ErsatzTV.Core.Tests.Metadata
Either<BaseError, Unit> result = await service.ScanFolder(
libraryPath,
FFprobePath,
DateTimeOffset.MinValue);
DateTimeOffset.MinValue,
0,
1);
result.IsRight.Should().BeTrue();
@@ -370,7 +386,9 @@ namespace ErsatzTV.Core.Tests.Metadata
Either<BaseError, Unit> result = await service.ScanFolder(
libraryPath,
FFprobePath,
DateTimeOffset.MinValue);
DateTimeOffset.MinValue,
0,
1);
result.IsRight.Should().BeTrue();
@@ -412,7 +430,9 @@ namespace ErsatzTV.Core.Tests.Metadata
Either<BaseError, Unit> result = await service.ScanFolder(
libraryPath,
FFprobePath,
DateTimeOffset.MinValue);
DateTimeOffset.MinValue,
0,
1);
result.IsRight.Should().BeTrue();
@@ -448,7 +468,9 @@ namespace ErsatzTV.Core.Tests.Metadata
Either<BaseError, Unit> result = await service.ScanFolder(
libraryPath,
FFprobePath,
DateTimeOffset.MinValue);
DateTimeOffset.MinValue,
0,
1);
result.IsRight.Should().BeTrue();
@@ -486,7 +508,9 @@ namespace ErsatzTV.Core.Tests.Metadata
Either<BaseError, Unit> result = await service.ScanFolder(
libraryPath,
FFprobePath,
DateTimeOffset.MinValue);
DateTimeOffset.MinValue,
0,
1);
result.IsRight.Should().BeTrue();
@@ -513,7 +537,9 @@ namespace ErsatzTV.Core.Tests.Metadata
Either<BaseError, Unit> result = await service.ScanFolder(
libraryPath,
FFprobePath,
DateTimeOffset.MinValue);
DateTimeOffset.MinValue,
0,
1);
result.IsRight.Should().BeTrue();
@@ -531,6 +557,8 @@ 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");
}
}
+3 -1
View File
@@ -1,4 +1,5 @@
using System.Collections.Generic;
using System;
using System.Collections.Generic;
namespace ErsatzTV.Core.Domain
{
@@ -6,6 +7,7 @@ namespace ErsatzTV.Core.Domain
{
public int Id { get; set; }
public string Path { get; set; }
public DateTime? LastScan { get; set; }
public int LibraryId { get; set; }
public Library Library { get; set; }
+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; }
}
}
+1
View File
@@ -11,6 +11,7 @@
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="LanguageExt.Core" Version="3.4.15" />
<PackageReference Include="MediatR" Version="9.0.0" />
<PackageReference Include="Microsoft.Extensions.Http" Version="5.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="5.0.0" />
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="16.9.60">
@@ -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;
+8 -4
View File
@@ -199,6 +199,7 @@ namespace ErsatzTV.Core.FFmpeg
"-c", "copy",
"-muxdelay", "0",
"-muxpreload", "0"
// "-avoid_negative_ts", "make_zero"
};
_arguments.AddRange(arguments);
return this;
@@ -353,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,13 +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);
Option<MusicVideoMetadata> GetFallbackMetadata(MusicVideo musicVideo);
string GetSortTitle(string title);
}
}
@@ -1,19 +1,21 @@
using System.Threading.Tasks;
using ErsatzTV.Core.Domain;
using LanguageExt;
namespace ErsatzTV.Core.Interfaces.Metadata
{
public interface ILocalMetadataProvider
{
Task<ShowMetadata> GetMetadataForShow(string showFolder);
Task<Option<MusicVideoMetadata>> GetMetadataForMusicVideo(string filePath);
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);
}
}
@@ -7,6 +7,11 @@ namespace ErsatzTV.Core.Interfaces.Metadata
{
public interface IMovieFolderScanner
{
Task<Either<BaseError, Unit>> ScanFolder(LibraryPath libraryPath, string ffprobePath, DateTimeOffset lastScan);
Task<Either<BaseError, Unit>> ScanFolder(
LibraryPath libraryPath,
string ffprobePath,
DateTimeOffset lastScan,
decimal progressMin,
decimal progressMax);
}
}
@@ -7,6 +7,11 @@ namespace ErsatzTV.Core.Interfaces.Metadata
{
public interface IMusicVideoFolderScanner
{
Task<Either<BaseError, Unit>> ScanFolder(LibraryPath libraryPath, string ffprobePath, DateTimeOffset lastScan);
Task<Either<BaseError, Unit>> ScanFolder(
LibraryPath libraryPath,
string ffprobePath,
DateTimeOffset lastScan,
decimal progressMin,
decimal progressMax);
}
}
@@ -7,6 +7,11 @@ namespace ErsatzTV.Core.Interfaces.Metadata
{
public interface ITelevisionFolderScanner
{
Task<Either<BaseError, Unit>> ScanFolder(LibraryPath libraryPath, string ffprobePath, DateTimeOffset lastScan);
Task<Either<BaseError, Unit>> ScanFolder(
LibraryPath libraryPath,
string ffprobePath,
DateTimeOffset lastScan,
decimal progressMin,
decimal progressMax);
}
}
@@ -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);
}
}
@@ -12,6 +12,7 @@ namespace ErsatzTV.Core.Interfaces.Repositories
Task<Option<LocalLibrary>> GetLocal(int libraryId);
Task<List<Library>> GetAll();
Task<Unit> UpdateLastScan(Library library);
Task<Unit> UpdateLastScan(LibraryPath libraryPath);
Task<List<LibraryPath>> GetLocalPaths(int libraryId);
Task<Option<LibraryPath>> GetPath(int libraryPathId);
Task<int> CountMediaItemsByPath(int libraryPathId);
@@ -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);
@@ -10,5 +10,6 @@ namespace ErsatzTV.Core.Interfaces.Repositories
Task<Option<MediaItem>> Get(int id);
Task<List<MediaItem>> GetAll();
Task<bool> Update(MediaItem mediaItem);
Task<List<string>> GetAllLanguageCodes();
}
}
@@ -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,12 +8,10 @@ namespace ErsatzTV.Core.Interfaces.Repositories
{
public interface IMusicVideoRepository
{
Task<Option<MusicVideo>> GetByMetadata(LibraryPath libraryPath, MusicVideoMetadata metadata);
Task<Either<BaseError, MediaItemScanResult<MusicVideo>>> Add(
Task<Either<BaseError, MediaItemScanResult<MusicVideo>>> GetOrAdd(
Artist artist,
LibraryPath libraryPath,
string filePath,
MusicVideoMetadata metadata);
string path);
Task<IEnumerable<string>> FindMusicVideoPaths(LibraryPath libraryPath);
Task<List<int>> DeleteByPath(LibraryPath libraryPath, string path);
@@ -22,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,6 +44,19 @@ namespace ErsatzTV.Core.Metadata
return fileName != null ? GetMovieMetadata(fileName, metadata) : metadata;
}
public Option<MusicVideoMetadata> GetFallbackMetadata(MusicVideo musicVideo)
{
string path = musicVideo.MediaVersions.Head().MediaFiles.Head().Path;
string fileName = Path.GetFileName(path);
var metadata = new MusicVideoMetadata
{
MetadataKind = MetadataKind.Fallback,
Title = fileName ?? path
};
return GetMusicVideoMetadata(fileName, metadata);
}
public string GetSortTitle(string title)
{
if (string.IsNullOrWhiteSpace(title))
@@ -112,6 +133,28 @@ namespace ErsatzTV.Core.Metadata
return metadata;
}
private Option<MusicVideoMetadata> GetMusicVideoMetadata(string fileName, MusicVideoMetadata metadata)
{
try
{
const string PATTERN = @"^(.*?) - (.*?).\w+$";
Match match = Regex.Match(fileName, PATTERN);
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)
{
return None;
}
}
private ShowMetadata GetTelevisionShowMetadata(string fileName, ShowMetadata metadata)
{
try
@@ -0,0 +1,6 @@
using MediatR;
namespace ErsatzTV.Core.Metadata
{
public record LibraryScanProgress(int LibraryId, decimal Progress) : INotification;
}
@@ -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"));
}
}
+152 -8
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,20 +74,26 @@ namespace ErsatzTV.Core.Metadata
});
}
public async Task<Option<MusicVideoMetadata>> GetMetadataForMusicVideo(string filePath)
public async Task<ArtistMetadata> GetMetadataForArtist(string artistFolder)
{
string nfoFileName = Path.ChangeExtension(filePath, "nfo");
Option<MusicVideoMetadata> maybeMetadata = None;
string nfoFileName = Path.Combine(artistFolder, "artist.nfo");
Option<ArtistMetadata> maybeMetadata = None;
if (_localFileSystem.FileExists(nfoFileName))
{
maybeMetadata = await LoadMusicVideoMetadata(nfoFileName);
maybeMetadata = await LoadArtistMetadata(nfoFileName);
}
return maybeMetadata.Map(
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;
});
}
@@ -105,11 +115,17 @@ 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(
metadata => ApplyMetadataUpdate(musicVideo, metadata),
() => Task.FromResult(false)));
() => RefreshFallbackMetadata(musicVideo)));
public Task<bool> RefreshFallbackMetadata(Movie movie) =>
ApplyMetadataUpdate(movie, _fallbackMetadataProvider.GetFallbackMetadata(movie));
@@ -117,6 +133,14 @@ 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) =>
_fallbackMetadataProvider.GetFallbackMetadata(musicVideo).Match(
metadata => ApplyMetadataUpdate(musicVideo, metadata),
() => Task.FromResult(false));
public Task<bool> RefreshFallbackMetadata(Show televisionShow, string showFolder) =>
ApplyMetadataUpdate(televisionShow, _fallbackMetadataProvider.GetFallbackMetadataForShow(showFolder));
@@ -132,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,
@@ -283,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;
@@ -357,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
@@ -164,7 +164,9 @@ namespace ErsatzTV.Core.Metadata
FFprobeStream videoStream = json.streams.FirstOrDefault(s => s.codec_type == "video");
if (videoStream != null)
{
version.SampleAspectRatio = videoStream.sample_aspect_ratio;
version.SampleAspectRatio = string.IsNullOrWhiteSpace(videoStream.sample_aspect_ratio)
? "1:1"
: videoStream.sample_aspect_ratio;
version.DisplayAspectRatio = videoStream.display_aspect_ratio;
version.Width = videoStream.width;
version.Height = videoStream.height;
+23 -3
View File
@@ -10,9 +10,11 @@ using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Search;
using LanguageExt;
using MediatR;
using Microsoft.Extensions.Logging;
using static LanguageExt.Prelude;
using Seq = LanguageExt.Seq;
using Unit = LanguageExt.Unit;
namespace ErsatzTV.Core.Metadata
{
@@ -21,8 +23,10 @@ namespace ErsatzTV.Core.Metadata
private readonly ILocalFileSystem _localFileSystem;
private readonly ILocalMetadataProvider _localMetadataProvider;
private readonly ILogger<MovieFolderScanner> _logger;
private readonly IMediator _mediator;
private readonly IMovieRepository _movieRepository;
private readonly ISearchIndex _searchIndex;
private readonly ISearchRepository _searchRepository;
public MovieFolderScanner(
ILocalFileSystem localFileSystem,
@@ -32,6 +36,8 @@ namespace ErsatzTV.Core.Metadata
IMetadataRepository metadataRepository,
IImageCache imageCache,
ISearchIndex searchIndex,
ISearchRepository searchRepository,
IMediator mediator,
ILogger<MovieFolderScanner> logger)
: base(localFileSystem, localStatisticsProvider, metadataRepository, imageCache, logger)
{
@@ -39,19 +45,27 @@ namespace ErsatzTV.Core.Metadata
_movieRepository = movieRepository;
_localMetadataProvider = localMetadataProvider;
_searchIndex = searchIndex;
_searchRepository = searchRepository;
_mediator = mediator;
_logger = logger;
}
public async Task<Either<BaseError, Unit>> ScanFolder(
LibraryPath libraryPath,
string ffprobePath,
DateTimeOffset lastScan)
DateTimeOffset lastScan,
decimal progressMin,
decimal progressMax)
{
decimal progressSpread = progressMax - progressMin;
if (!_localFileSystem.IsLibraryPathAccessible(libraryPath))
{
return new MediaSourceInaccessible();
}
var foldersCompleted = 0;
var folderQueue = new Queue<string>();
foreach (string folder in _localFileSystem.ListSubdirectories(libraryPath.Path).OrderBy(identity))
{
@@ -60,7 +74,12 @@ namespace ErsatzTV.Core.Metadata
while (folderQueue.Count > 0)
{
decimal percentCompletion = (decimal) foldersCompleted / (foldersCompleted + folderQueue.Count);
await _mediator.Publish(
new LibraryScanProgress(libraryPath.LibraryId, progressMin + percentCompletion * progressSpread));
string movieFolder = folderQueue.Dequeue();
foldersCompleted++;
var allFiles = _localFileSystem.ListFiles(movieFolder)
.Filter(f => VideoFileExtensions.Contains(Path.GetExtension(f)))
@@ -99,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 =>
@@ -124,6 +143,7 @@ namespace ErsatzTV.Core.Metadata
}
}
_searchIndex.Commit();
return Unit.Default;
}
+226 -62
View File
@@ -10,18 +10,23 @@ using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Search;
using LanguageExt;
using MediatR;
using Microsoft.Extensions.Logging;
using static LanguageExt.Prelude;
using Unit = LanguageExt.Unit;
namespace ErsatzTV.Core.Metadata
{
public class 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,
@@ -30,7 +35,10 @@ namespace ErsatzTV.Core.Metadata
IMetadataRepository metadataRepository,
IImageCache imageCache,
ISearchIndex searchIndex,
ISearchRepository searchRepository,
IArtistRepository artistRepository,
IMusicVideoRepository musicVideoRepository,
IMediator mediator,
ILogger<MusicVideoFolderScanner> logger) : base(
localFileSystem,
localStatisticsProvider,
@@ -41,48 +49,205 @@ namespace ErsatzTV.Core.Metadata
_localFileSystem = localFileSystem;
_localMetadataProvider = localMetadataProvider;
_searchIndex = searchIndex;
_searchRepository = searchRepository;
_artistRepository = artistRepository;
_musicVideoRepository = musicVideoRepository;
_mediator = mediator;
_logger = logger;
}
public async Task<Either<BaseError, Unit>> ScanFolder(
LibraryPath libraryPath,
string ffprobePath,
DateTimeOffset lastScan)
DateTimeOffset lastScan,
decimal progressMin,
decimal progressMax)
{
decimal progressSpread = progressMax - progressMin;
if (!_localFileSystem.IsLibraryPathAccessible(libraryPath))
{
return new MediaSourceInaccessible();
}
var folderQueue = new Queue<string>();
foreach (string folder in _localFileSystem.ListSubdirectories(libraryPath.Path).OrderBy(identity))
var allArtistFolders = _localFileSystem.ListSubdirectories(libraryPath.Path)
.Filter(ShouldIncludeFolder)
.OrderBy(identity)
.ToList();
foreach (string artistFolder in allArtistFolders)
{
folderQueue.Enqueue(folder);
// _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 movieFolder = folderQueue.Dequeue();
string musicVideoFolder = folderQueue.Dequeue();
// _logger.LogDebug("Scanning music video folder {Folder}", musicVideoFolder);
var allFiles = _localFileSystem.ListFiles(movieFolder)
var allFiles = _localFileSystem.ListFiles(musicVideoFolder)
.Filter(f => VideoFileExtensions.Contains(Path.GetExtension(f)))
.Filter(
f => !ExtraFiles.Any(
e => Path.GetFileNameWithoutExtension(f).EndsWith(e, StringComparison.OrdinalIgnoreCase)))
.ToList();
if (allFiles.Count == 0)
foreach (string subdirectory in _localFileSystem.ListSubdirectories(musicVideoFolder)
.OrderBy(identity))
{
foreach (string subdirectory in _localFileSystem.ListSubdirectories(movieFolder).OrderBy(identity))
{
folderQueue.Enqueue(subdirectory);
}
continue;
folderQueue.Enqueue(subdirectory);
}
if (_localFileSystem.GetLastWriteTime(movieFolder) < lastScan)
if (_localFileSystem.GetLastWriteTime(musicVideoFolder) < lastScan)
{
continue;
}
@@ -90,22 +255,22 @@ namespace ErsatzTV.Core.Metadata
foreach (string file in allFiles.OrderBy(identity))
{
// TODO: figure out how to rebuild playouts
Either<BaseError, MediaItemScanResult<MusicVideo>> maybeMusicVideo =
await FindOrCreateMusicVideo(libraryPath, file)
.BindT(musicVideo => UpdateStatistics(musicVideo, ffprobePath))
.BindT(UpdateMetadata)
.BindT(UpdateThumbnail);
Either<BaseError, MediaItemScanResult<MusicVideo>> maybeMusicVideo = await _musicVideoRepository
.GetOrAdd(artist, libraryPath, file)
.BindT(musicVideo => UpdateStatistics(musicVideo, ffprobePath))
.BindT(UpdateMetadata)
.BindT(UpdateThumbnail);
await maybeMusicVideo.Match(
async result =>
{
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 =>
@@ -115,39 +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>>> FindOrCreateMusicVideo(
LibraryPath libraryPath,
string filePath)
{
Option<MusicVideoMetadata> maybeMetadata = await _localMetadataProvider.GetMetadataForMusicVideo(filePath);
return await maybeMetadata.Match(
async metadata =>
{
Option<MusicVideo> maybeMusicVideo =
await _musicVideoRepository.GetByMetadata(libraryPath, metadata);
return await maybeMusicVideo.Match(
musicVideo =>
Right<BaseError, MediaItemScanResult<MusicVideo>>(
new MediaItemScanResult<MusicVideo>(musicVideo))
.AsTask(),
async () => await _musicVideoRepository.Add(libraryPath, filePath, metadata));
},
() => Left<BaseError, MediaItemScanResult<MusicVideo>>(
BaseError.New("Unable to locate metadata for music video")).AsTask());
}
private async Task<Either<BaseError, MediaItemScanResult<MusicVideo>>> UpdateMetadata(
@@ -156,7 +288,7 @@ namespace ErsatzTV.Core.Metadata
try
{
MusicVideo musicVideo = result.Item;
return await LocateNfoFile(musicVideo).Match<Task<Either<BaseError, MediaItemScanResult<MusicVideo>>>>(
await LocateNfoFile(musicVideo).Match(
async nfoFile =>
{
bool shouldUpdate = Optional(musicVideo.MusicVideoMetadata).Flatten().HeadOrNone().Match(
@@ -172,11 +304,23 @@ namespace ErsatzTV.Core.Metadata
result.IsUpdated = true;
}
}
return result;
},
() => Left<BaseError, MediaItemScanResult<MusicVideo>>(
BaseError.New("Unable to locate metadata for music video")).AsTask());
async () =>
{
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))
{
result.IsUpdated = true;
}
}
});
return result;
}
catch (Exception ex)
{
@@ -184,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; }
}
}
@@ -10,8 +10,10 @@ using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Search;
using LanguageExt;
using MediatR;
using Microsoft.Extensions.Logging;
using static LanguageExt.Prelude;
using Unit = LanguageExt.Unit;
namespace ErsatzTV.Core.Metadata
{
@@ -20,7 +22,9 @@ namespace ErsatzTV.Core.Metadata
private readonly ILocalFileSystem _localFileSystem;
private readonly ILocalMetadataProvider _localMetadataProvider;
private readonly ILogger<TelevisionFolderScanner> _logger;
private readonly IMediator _mediator;
private readonly ISearchIndex _searchIndex;
private readonly ISearchRepository _searchRepository;
private readonly ITelevisionRepository _televisionRepository;
public TelevisionFolderScanner(
@@ -31,6 +35,8 @@ namespace ErsatzTV.Core.Metadata
IMetadataRepository metadataRepository,
IImageCache imageCache,
ISearchIndex searchIndex,
ISearchRepository searchRepository,
IMediator mediator,
ILogger<TelevisionFolderScanner> logger) : base(
localFileSystem,
localStatisticsProvider,
@@ -42,14 +48,20 @@ namespace ErsatzTV.Core.Metadata
_televisionRepository = televisionRepository;
_localMetadataProvider = localMetadataProvider;
_searchIndex = searchIndex;
_searchRepository = searchRepository;
_mediator = mediator;
_logger = logger;
}
public async Task<Either<BaseError, Unit>> ScanFolder(
LibraryPath libraryPath,
string ffprobePath,
DateTimeOffset lastScan)
DateTimeOffset lastScan,
decimal progressMin,
decimal progressMax)
{
decimal progressSpread = progressMax - progressMin;
if (!_localFileSystem.IsLibraryPathAccessible(libraryPath))
{
return new MediaSourceInaccessible();
@@ -62,6 +74,10 @@ namespace ErsatzTV.Core.Metadata
foreach (string showFolder in allShowFolders)
{
decimal percentCompletion = (decimal) allShowFolders.IndexOf(showFolder) / allShowFolders.Count;
await _mediator.Publish(
new LibraryScanProgress(libraryPath.LibraryId, progressMin + percentCompletion * progressSpread));
Either<BaseError, MediaItemScanResult<Show>> maybeShow =
await FindOrCreateShow(libraryPath.Id, showFolder)
.BindT(show => UpdateMetadataForShow(show, showFolder))
@@ -71,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,
@@ -87,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 =>
{
@@ -111,6 +127,7 @@ namespace ErsatzTV.Core.Metadata
List<int> ids = await _televisionRepository.DeleteEmptyShows(libraryPath);
await _searchIndex.RemoveItems(ids);
_searchIndex.Commit();
return Unit.Default;
}
@@ -384,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))
+8 -1
View File
@@ -3,16 +3,21 @@ using System.Threading.Tasks;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using LanguageExt;
using Microsoft.Extensions.Logging;
using static LanguageExt.Prelude;
namespace ErsatzTV.Core.Plex
{
public abstract class PlexLibraryScanner
{
private readonly ILogger<PlexLibraryScanner> _logger;
private readonly IMetadataRepository _metadataRepository;
protected PlexLibraryScanner(IMetadataRepository metadataRepository) =>
protected PlexLibraryScanner(IMetadataRepository metadataRepository, ILogger<PlexLibraryScanner> logger)
{
_metadataRepository = metadataRepository;
_logger = logger;
}
protected async Task<Unit> UpdateArtworkIfNeeded(
Domain.Metadata existingMetadata,
@@ -27,6 +32,8 @@ namespace ErsatzTV.Core.Plex
await maybeIncomingArtwork.Match(
async incomingArtwork =>
{
_logger.LogDebug("Refreshing Plex {Attribute} from {Path}", artworkKind, incomingArtwork.Path);
Option<Artwork> maybeExistingArtwork = Optional(existingMetadata.Artwork).Flatten()
.Find(a => a.ArtworkKind == artworkKind);
+29 -3
View File
@@ -7,30 +7,38 @@ using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Core.Metadata;
using LanguageExt;
using MediatR;
using Microsoft.Extensions.Logging;
using Unit = LanguageExt.Unit;
namespace ErsatzTV.Core.Plex
{
public class PlexMovieLibraryScanner : PlexLibraryScanner, IPlexMovieLibraryScanner
{
private readonly ILogger<PlexMovieLibraryScanner> _logger;
private readonly IMediator _mediator;
private readonly IMetadataRepository _metadataRepository;
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)
: base(metadataRepository, logger)
{
_plexServerApiClient = plexServerApiClient;
_movieRepository = movieRepository;
_metadataRepository = metadataRepository;
_searchIndex = searchIndex;
_searchRepository = searchRepository;
_mediator = mediator;
_logger = logger;
}
@@ -49,6 +57,9 @@ namespace ErsatzTV.Core.Plex
{
foreach (PlexMovie incoming in movieEntries)
{
decimal percentCompletion = (decimal) movieEntries.IndexOf(incoming) / movieEntries.Count;
await _mediator.Publish(new LibraryScanProgress(plexMediaSourceLibrary.Id, percentCompletion));
// TODO: figure out how to rebuild playlists
Either<BaseError, MediaItemScanResult<PlexMovie>> maybeMovie = await _movieRepository
.GetOrAdd(plexMediaSourceLibrary, incoming)
@@ -61,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 =>
@@ -81,6 +94,8 @@ namespace ErsatzTV.Core.Plex
var movieKeys = movieEntries.Map(s => s.Key).ToList();
List<int> ids = await _movieRepository.RemoveMissingPlexMovies(plexMediaSourceLibrary, movieKeys);
await _searchIndex.RemoveItems(ids);
await _mediator.Publish(new LibraryScanProgress(plexMediaSourceLibrary.Id, 0));
},
error =>
{
@@ -92,6 +107,7 @@ namespace ErsatzTV.Core.Plex
return Task.CompletedTask;
});
_searchIndex.Commit();
return Unit.Default;
}
@@ -113,6 +129,11 @@ namespace ErsatzTV.Core.Plex
await maybeStatistics.Match(
async mediaVersion =>
{
_logger.LogDebug(
"Refreshing {Attribute} from {Path}",
"Plex Statistics",
existingVersion.MediaFiles.Head().Path);
existingVersion.SampleAspectRatio = mediaVersion.SampleAspectRatio;
existingVersion.VideoScanKind = mediaVersion.VideoScanKind;
existingVersion.DateUpdated = mediaVersion.DateUpdated;
@@ -135,6 +156,11 @@ namespace ErsatzTV.Core.Plex
if (incomingMetadata.DateUpdated > existingMetadata.DateUpdated)
{
_logger.LogDebug(
"Refreshing {Attribute} from {Path}",
"Plex Metadata",
existing.MediaVersions.Head().MediaFiles.Head().Path);
foreach (Genre genre in existingMetadata.Genres
.Filter(g => incomingMetadata.Genres.All(g2 => g2.Name != g.Name))
.ToList())
@@ -7,17 +7,21 @@ using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Core.Metadata;
using LanguageExt;
using MediatR;
using Microsoft.Extensions.Logging;
using static LanguageExt.Prelude;
using Unit = LanguageExt.Unit;
namespace ErsatzTV.Core.Plex
{
public class PlexTelevisionLibraryScanner : PlexLibraryScanner, IPlexTelevisionLibraryScanner
{
private readonly ILogger<PlexTelevisionLibraryScanner> _logger;
private readonly IMediator _mediator;
private readonly IMetadataRepository _metadataRepository;
private readonly IPlexServerApiClient _plexServerApiClient;
private readonly ISearchIndex _searchIndex;
private readonly ISearchRepository _searchRepository;
private readonly ITelevisionRepository _televisionRepository;
public PlexTelevisionLibraryScanner(
@@ -25,13 +29,17 @@ namespace ErsatzTV.Core.Plex
ITelevisionRepository televisionRepository,
IMetadataRepository metadataRepository,
ISearchIndex searchIndex,
ISearchRepository searchRepository,
IMediator mediator,
ILogger<PlexTelevisionLibraryScanner> logger)
: base(metadataRepository)
: base(metadataRepository, logger)
{
_plexServerApiClient = plexServerApiClient;
_televisionRepository = televisionRepository;
_metadataRepository = metadataRepository;
_searchIndex = searchIndex;
_searchRepository = searchRepository;
_mediator = mediator;
_logger = logger;
}
@@ -50,6 +58,9 @@ namespace ErsatzTV.Core.Plex
{
foreach (PlexShow incoming in showEntries)
{
decimal percentCompletion = (decimal) showEntries.IndexOf(incoming) / showEntries.Count;
await _mediator.Publish(new LibraryScanProgress(plexMediaSourceLibrary.Id, percentCompletion));
// TODO: figure out how to rebuild playlists
Either<BaseError, MediaItemScanResult<PlexShow>> maybeShow = await _televisionRepository
.GetOrAddPlexShow(plexMediaSourceLibrary, incoming)
@@ -59,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 =>
{
@@ -85,6 +98,9 @@ namespace ErsatzTV.Core.Plex
await _televisionRepository.RemoveMissingPlexShows(plexMediaSourceLibrary, showKeys);
await _searchIndex.RemoveItems(ids);
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);
}
}
@@ -62,6 +62,10 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
"UPDATE Library SET LastScan = @LastScan WHERE Id = @Id",
new { library.LastScan, library.Id }).ToUnit();
public Task<Unit> UpdateLastScan(LibraryPath libraryPath) => _dbConnection.ExecuteAsync(
"UPDATE LibraryPath SET LastScan = @LastScan WHERE Id = @Id",
new { libraryPath.LastScan, libraryPath.Id }).ToUnit();
public Task<List<LibraryPath>> GetLocalPaths(int libraryId)
{
using TvContext context = _dbContextFactory.CreateDbContext();
@@ -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();
}
@@ -1,6 +1,8 @@
using System.Collections.Generic;
using System.Data;
using System.Linq;
using System.Threading.Tasks;
using Dapper;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using LanguageExt;
@@ -11,10 +13,14 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
{
public class MediaItemRepository : IMediaItemRepository
{
private readonly IDbConnection _dbConnection;
private readonly IDbContextFactory<TvContext> _dbContextFactory;
public MediaItemRepository(IDbContextFactory<TvContext> dbContextFactory) =>
public MediaItemRepository(IDbContextFactory<TvContext> dbContextFactory, IDbConnection dbConnection)
{
_dbContextFactory = dbContextFactory;
_dbConnection = dbConnection;
}
public async Task<Option<MediaItem>> Get(int id)
{
@@ -38,5 +44,16 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
context.MediaItems.Update(mediaItem);
return await context.SaveChangesAsync() > 0;
}
public Task<List<string>> GetAllLanguageCodes() =>
_dbConnection.QueryAsync<string>(
@"SELECT LanguageCode FROM
(SELECT Language AS LanguageCode
FROM MediaStream WHERE Language IS NOT NULL
UNION ALL SELECT PreferredLanguageCode AS LanguageCode
FROM Channel WHERE PreferredLanguageCode IS NOT NULL)
GROUP BY LanguageCode
ORDER BY COUNT(LanguageCode) DESC")
.Map(result => result.ToList());
}
}
@@ -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);
}
}
@@ -25,81 +25,48 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
_dbConnection = dbConnection;
}
public async Task<Option<MusicVideo>> GetByMetadata(LibraryPath libraryPath, MusicVideoMetadata metadata)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
Option<int> maybeId = await dbContext.MusicVideoMetadata
.Where(s => s.Artist == metadata.Artist && s.Title == metadata.Title && s.Year == metadata.Year)
.Where(s => s.MusicVideo.LibraryPathId == libraryPath.Id)
.SingleOrDefaultAsync()
.Map(Optional)
.MapT(sm => sm.MusicVideoId);
return await maybeId.Match(
id =>
{
return dbContext.MusicVideos
.AsNoTracking()
.Include(mv => mv.MusicVideoMetadata)
.ThenInclude(mvm => mvm.Artwork)
.Include(mv => mv.MusicVideoMetadata)
.ThenInclude(mvm => mvm.Genres)
.Include(mv => mv.MusicVideoMetadata)
.ThenInclude(mvm => mvm.Tags)
.Include(mv => mv.MusicVideoMetadata)
.ThenInclude(mvm => mvm.Studios)
.Include(mv => mv.LibraryPath)
.ThenInclude(lp => lp.Library)
.Include(mv => mv.MediaVersions)
.ThenInclude(mv => mv.MediaFiles)
.OrderBy(mv => mv.Id)
.SingleOrDefaultAsync(mv => mv.Id == id)
.Map(Optional);
},
() => Option<MusicVideo>.None.AsTask());
}
public async Task<Either<BaseError, MediaItemScanResult<MusicVideo>>> Add(
public async Task<Either<BaseError, MediaItemScanResult<MusicVideo>>> GetOrAdd(
Artist artist,
LibraryPath libraryPath,
string filePath,
MusicVideoMetadata metadata)
string path)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
Option<MusicVideo> maybeExisting = await dbContext.MusicVideos
.AsNoTracking()
.Include(mv => mv.MusicVideoMetadata)
.ThenInclude(mvm => mvm.Artwork)
.Include(mv => mv.MusicVideoMetadata)
.ThenInclude(mvm => mvm.Genres)
.Include(mv => mv.MusicVideoMetadata)
.ThenInclude(mvm => mvm.Tags)
.Include(mv => mv.MusicVideoMetadata)
.ThenInclude(mvm => mvm.Studios)
.Include(mv => mv.LibraryPath)
.ThenInclude(lp => lp.Library)
.Include(mv => mv.MediaVersions)
.ThenInclude(mv => mv.MediaFiles)
.Include(mv => mv.MediaVersions)
.ThenInclude(mv => mv.Streams)
.OrderBy(i => i.MediaVersions.First().MediaFiles.First().Path)
.SingleOrDefaultAsync(i => i.MediaVersions.First().MediaFiles.First().Path == path);
try
{
metadata.DateAdded = DateTime.UtcNow;
metadata.Genres ??= new List<Genre>();
metadata.Tags ??= new List<Tag>();
metadata.Studios ??= new List<Studio>();
var musicVideo = new MusicVideo
return await maybeExisting.Match(
async mediaItem =>
{
LibraryPathId = libraryPath.Id,
MusicVideoMetadata = new List<MusicVideoMetadata> { metadata },
MediaVersions = new List<MediaVersion>
if (mediaItem.ArtistId != artist.Id)
{
new()
{
MediaFiles = new List<MediaFile>
{
new() { Path = filePath }
},
Streams = new List<MediaStream>()
}
await _dbConnection.ExecuteAsync(
@"UPDATE MusicVideo SET ArtistId = @ArtistId WHERE Id = @Id",
new { mediaItem.Id, ArtistId = artist.Id });
mediaItem.ArtistId = artist.Id;
mediaItem.Artist = artist;
}
};
await dbContext.MusicVideos.AddAsync(musicVideo);
await dbContext.SaveChangesAsync();
await dbContext.Entry(musicVideo).Reference(s => s.LibraryPath).LoadAsync();
await dbContext.Entry(musicVideo.LibraryPath).Reference(lp => lp.Library).LoadAsync();
return new MediaItemScanResult<MusicVideo>(musicVideo) { IsAdded = true };
}
catch (Exception ex)
{
return BaseError.New(ex.Message);
}
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) =>
@@ -118,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());
@@ -156,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();
@@ -177,5 +147,77 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.SingleOrDefaultAsync(m => m.Id == musicVideoId)
.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)
{
try
{
var musicVideo = new MusicVideo
{
ArtistId = artist.Id,
LibraryPathId = libraryPathId,
MediaVersions = new List<MediaVersion>
{
new()
{
MediaFiles = new List<MediaFile>
{
new() { Path = path }
},
Streams = new List<MediaStream>()
}
}
};
await dbContext.MusicVideos.AddAsync(musicVideo);
await dbContext.SaveChangesAsync();
await dbContext.Entry(musicVideo).Reference(m => m.LibraryPath).LoadAsync();
await dbContext.Entry(musicVideo.LibraryPath).Reference(lp => lp.Library).LoadAsync();
return new MediaItemScanResult<MusicVideo>(musicVideo) { IsAdded = true };
}
catch (Exception ex)
{
return BaseError.New(ex.Message);
}
}
}
}
@@ -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());
}
}

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