Compare commits
47
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4c70d61d48 | ||
|
|
00fdc272e9 | ||
|
|
f04c18c810 | ||
|
|
eca58dbe7f | ||
|
|
cf9479d2a9 | ||
|
|
b6331331b0 | ||
|
|
ed365cfa43 | ||
|
|
b3a1e71570 | ||
|
|
454343d14f | ||
|
|
c0a6677861 | ||
|
|
2efcbca2da | ||
|
|
f96efa9b2f | ||
|
|
f46041305c | ||
|
|
493a496b91 | ||
|
|
739d074bc6 | ||
|
|
c5c28cb92d | ||
|
|
636bf0715b | ||
|
|
0ca15ee7a8 | ||
|
|
6565240eeb | ||
|
|
d64188927c | ||
|
|
0ecec3cb07 | ||
|
|
a8e861abc0 | ||
|
|
76446e0d69 | ||
|
|
c6d90ad750 | ||
|
|
e5a9ef6196 | ||
|
|
8439d6fd54 | ||
|
|
1773691c39 | ||
|
|
940cdd10a3 | ||
|
|
6beb9f7e33 | ||
|
|
898a21dcd9 | ||
|
|
a01888792a | ||
|
|
8b1f8dd36b | ||
|
|
e9b26d6bdb | ||
|
|
79b2e9dbfe | ||
|
|
9ba0cbd84f | ||
|
|
d5b48d2601 | ||
|
|
aa938baec8 | ||
|
|
a13f964200 | ||
|
|
0da9701f9c | ||
|
|
b3f4c22f49 | ||
|
|
50fafbfb98 | ||
|
|
914d128610 | ||
|
|
1a2f36f561 | ||
|
|
96887fbd79 | ||
|
|
c07e2afff4 | ||
|
|
4953617f79 | ||
|
|
1587ac7d62 |
+12
-14
@@ -51,16 +51,17 @@ jobs:
|
||||
final="${tag2/prealpha/$short}"
|
||||
echo "GIT_TAG=${final}" >> $GITHUB_ENV
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
- name: Set up Docker Buildx Base
|
||||
uses: docker/setup-buildx-action@v1
|
||||
id: builder-base
|
||||
|
||||
- name: Cache Docker layers
|
||||
uses: actions/cache@v2.1.4
|
||||
with:
|
||||
path: /tmp/.buildx-cache
|
||||
key: ${{ runner.os }}-buildx-${{ github.sha }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-buildx-
|
||||
- name: Set up Docker Buildx NVIDIA
|
||||
uses: docker/setup-buildx-action@v1
|
||||
id: builder-nvidia
|
||||
|
||||
- name: Set up Docker Buildx VAAPI
|
||||
uses: docker/setup-buildx-action@v1
|
||||
id: builder-vaapi
|
||||
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@v1
|
||||
@@ -71,6 +72,7 @@ jobs:
|
||||
- name: Build and push base
|
||||
uses: docker/build-push-action@v2
|
||||
with:
|
||||
builder: ${{ steps.builder-base.outputs.name }}
|
||||
context: .
|
||||
file: ./docker/Dockerfile
|
||||
push: true
|
||||
@@ -79,12 +81,11 @@ jobs:
|
||||
tags: |
|
||||
jasongdove/ersatztv:develop
|
||||
jasongdove/ersatztv:${{ github.sha }}
|
||||
cache-from: type=local,src=/tmp/.buildx-cache
|
||||
cache-to: type=local,dest=/tmp/.buildx-cache,mode=max
|
||||
|
||||
- name: Build and push nvidia
|
||||
uses: docker/build-push-action@v2
|
||||
with:
|
||||
builder: ${{ steps.builder-nvidia.outputs.name }}
|
||||
context: .
|
||||
file: ./docker/nvidia/Dockerfile
|
||||
push: true
|
||||
@@ -93,12 +94,11 @@ jobs:
|
||||
tags: |
|
||||
jasongdove/ersatztv:develop-nvidia
|
||||
jasongdove/ersatztv:${{ github.sha }}-nvidia
|
||||
cache-from: type=local,src=/tmp/.buildx-cache
|
||||
cache-to: type=local,dest=/tmp/.buildx-cache,mode=max
|
||||
|
||||
- name: Build and push vaapi
|
||||
uses: docker/build-push-action@v2
|
||||
with:
|
||||
builder: ${{ steps.builder-vaapi.outputs.name }}
|
||||
context: .
|
||||
file: ./docker/vaapi/Dockerfile
|
||||
push: true
|
||||
@@ -107,5 +107,3 @@ jobs:
|
||||
tags: |
|
||||
jasongdove/ersatztv:develop-vaapi
|
||||
jasongdove/ersatztv:${{ github.sha }}-vaapi
|
||||
cache-from: type=local,src=/tmp/.buildx-cache
|
||||
cache-to: type=local,dest=/tmp/.buildx-cache,mode=max
|
||||
|
||||
@@ -83,16 +83,17 @@ jobs:
|
||||
echo "GIT_TAG=${tag:1}" >> $GITHUB_ENV
|
||||
echo "DOCKER_TAG=${tag/-prealpha/}" >> $GITHUB_ENV
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
- name: Set up Docker Buildx Base
|
||||
uses: docker/setup-buildx-action@v1
|
||||
id: builder-base
|
||||
|
||||
- name: Cache Docker layers
|
||||
uses: actions/cache@v2.1.4
|
||||
with:
|
||||
path: /tmp/.buildx-cache
|
||||
key: ${{ runner.os }}-buildx-${{ github.sha }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-buildx-
|
||||
- name: Set up Docker Buildx NVIDIA
|
||||
uses: docker/setup-buildx-action@v1
|
||||
id: builder-nvidia
|
||||
|
||||
- name: Set up Docker Buildx VAAPI
|
||||
uses: docker/setup-buildx-action@v1
|
||||
id: builder-vaapi
|
||||
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@v1
|
||||
@@ -103,6 +104,7 @@ jobs:
|
||||
- name: Build and push base
|
||||
uses: docker/build-push-action@v2
|
||||
with:
|
||||
builder: ${{ steps.builder-base.outputs.name }}
|
||||
context: .
|
||||
file: ./docker/Dockerfile
|
||||
push: true
|
||||
@@ -111,12 +113,11 @@ jobs:
|
||||
tags: |
|
||||
jasongdove/ersatztv:latest
|
||||
jasongdove/ersatztv:${{ env.DOCKER_TAG }}
|
||||
cache-from: type=local,src=/tmp/.buildx-cache
|
||||
cache-to: type=local,dest=/tmp/.buildx-cache,mode=max
|
||||
|
||||
- name: Build and push nvidia
|
||||
uses: docker/build-push-action@v2
|
||||
with:
|
||||
builder: ${{ steps.builder-nvidia.outputs.name }}
|
||||
context: .
|
||||
file: ./docker/nvidia/Dockerfile
|
||||
push: true
|
||||
@@ -125,12 +126,11 @@ jobs:
|
||||
tags: |
|
||||
jasongdove/ersatztv:latest-nvidia
|
||||
jasongdove/ersatztv:${{ env.DOCKER_TAG }}-nvidia
|
||||
cache-from: type=local,src=/tmp/.buildx-cache
|
||||
cache-to: type=local,dest=/tmp/.buildx-cache,mode=max
|
||||
|
||||
- name: Build and push vaapi
|
||||
uses: docker/build-push-action@v2
|
||||
with:
|
||||
builder: ${{ steps.builder-vaapi.outputs.name }}
|
||||
context: .
|
||||
file: ./docker/vaapi/Dockerfile
|
||||
push: true
|
||||
@@ -139,5 +139,3 @@ jobs:
|
||||
tags: |
|
||||
jasongdove/ersatztv:latest-vaapi
|
||||
jasongdove/ersatztv:${{ env.DOCKER_TAG }}-vaapi
|
||||
cache-from: type=local,src=/tmp/.buildx-cache
|
||||
cache-to: type=local,dest=/tmp/.buildx-cache,mode=max
|
||||
|
||||
@@ -3,9 +3,9 @@ using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using LanguageExt;
|
||||
using MediatR;
|
||||
using static ErsatzTV.Application.Channels.Mapper;
|
||||
using static LanguageExt.Prelude;
|
||||
|
||||
namespace ErsatzTV.Application.Channels.Queries
|
||||
{
|
||||
@@ -15,7 +15,7 @@ namespace ErsatzTV.Application.Channels.Queries
|
||||
|
||||
public GetAllChannelsHandler(IChannelRepository channelRepository) => _channelRepository = channelRepository;
|
||||
|
||||
public Task<List<ChannelViewModel>> Handle(GetAllChannels request, CancellationToken cancellationToken) =>
|
||||
_channelRepository.GetAll().Map(channels => channels.Map(ProjectToViewModel).ToList());
|
||||
public async Task<List<ChannelViewModel>> Handle(GetAllChannels request, CancellationToken cancellationToken) =>
|
||||
Optional(await _channelRepository.GetAll()).Flatten().Map(ProjectToViewModel).ToList();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using MediatR;
|
||||
using ErsatzTV.Core;
|
||||
using LanguageExt;
|
||||
|
||||
namespace ErsatzTV.Application.FFmpegProfiles.Commands
|
||||
{
|
||||
public record UpdateFFmpegSettings(FFmpegSettingsViewModel Settings) : IRequest;
|
||||
public record UpdateFFmpegSettings(FFmpegSettingsViewModel Settings) : MediatR.IRequest<Either<BaseError, Unit>>;
|
||||
}
|
||||
|
||||
@@ -1,28 +1,77 @@
|
||||
using System.Threading;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using LanguageExt;
|
||||
using MediatR;
|
||||
using Unit = MediatR.Unit;
|
||||
|
||||
namespace ErsatzTV.Application.FFmpegProfiles.Commands
|
||||
{
|
||||
public class UpdateFFmpegSettingsHandler : IRequestHandler<UpdateFFmpegSettings>
|
||||
public class UpdateFFmpegSettingsHandler : MediatR.IRequestHandler<UpdateFFmpegSettings, Either<BaseError, Unit>>
|
||||
{
|
||||
private readonly IConfigElementRepository _configElementRepository;
|
||||
private readonly ILocalFileSystem _localFileSystem;
|
||||
|
||||
public UpdateFFmpegSettingsHandler(IConfigElementRepository configElementRepository) =>
|
||||
_configElementRepository = configElementRepository;
|
||||
|
||||
public async Task<Unit> Handle(UpdateFFmpegSettings request, CancellationToken cancellationToken)
|
||||
public UpdateFFmpegSettingsHandler(
|
||||
IConfigElementRepository configElementRepository,
|
||||
ILocalFileSystem localFileSystem)
|
||||
{
|
||||
Option<ConfigElement> ffmpegPath = await _configElementRepository.Get(ConfigElementKey.FFmpegPath);
|
||||
Option<ConfigElement> ffprobePath = await _configElementRepository.Get(ConfigElementKey.FFprobePath);
|
||||
Option<ConfigElement> defaultFFmpegProfileId =
|
||||
await _configElementRepository.Get(ConfigElementKey.FFmpegDefaultProfileId);
|
||||
_configElementRepository = configElementRepository;
|
||||
_localFileSystem = localFileSystem;
|
||||
}
|
||||
|
||||
ffmpegPath.Match(
|
||||
public Task<Either<BaseError, Unit>> Handle(
|
||||
UpdateFFmpegSettings request,
|
||||
CancellationToken cancellationToken) =>
|
||||
Validate(request)
|
||||
.MapT(_ => ApplyUpdate(request))
|
||||
.Bind(v => v.ToEitherAsync());
|
||||
|
||||
private async Task<Validation<BaseError, Unit>> Validate(UpdateFFmpegSettings request) =>
|
||||
(await FFmpegMustExist(request), await FFprobeMustExist(request))
|
||||
.Apply((_, _) => Unit.Default);
|
||||
|
||||
private Task<Validation<BaseError, Unit>> FFmpegMustExist(UpdateFFmpegSettings request) =>
|
||||
ValidateToolPath(request.Settings.FFmpegPath, "ffmpeg");
|
||||
|
||||
private Task<Validation<BaseError, Unit>> FFprobeMustExist(UpdateFFmpegSettings request) =>
|
||||
ValidateToolPath(request.Settings.FFprobePath, "ffprobe");
|
||||
|
||||
private async Task<Validation<BaseError, Unit>> ValidateToolPath(string path, string name)
|
||||
{
|
||||
if (!_localFileSystem.FileExists(path))
|
||||
{
|
||||
return BaseError.New($"{name} path does not exist");
|
||||
}
|
||||
|
||||
var startInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = path,
|
||||
Arguments = "-version",
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false
|
||||
};
|
||||
|
||||
var test = new Process
|
||||
{
|
||||
StartInfo = startInfo
|
||||
};
|
||||
|
||||
test.Start();
|
||||
string output = await test.StandardOutput.ReadToEndAsync();
|
||||
await test.WaitForExitAsync();
|
||||
return test.ExitCode == 0 && output.Contains($"{name} version")
|
||||
? Unit.Default
|
||||
: BaseError.New($"Unable to verify {name} version");
|
||||
}
|
||||
|
||||
private async Task<Unit> ApplyUpdate(UpdateFFmpegSettings request)
|
||||
{
|
||||
await _configElementRepository.Get(ConfigElementKey.FFmpegPath).Match(
|
||||
ce =>
|
||||
{
|
||||
ce.Value = request.Settings.FFmpegPath;
|
||||
@@ -35,7 +84,7 @@ namespace ErsatzTV.Application.FFmpegProfiles.Commands
|
||||
_configElementRepository.Add(ce);
|
||||
});
|
||||
|
||||
ffprobePath.Match(
|
||||
await _configElementRepository.Get(ConfigElementKey.FFprobePath).Match(
|
||||
ce =>
|
||||
{
|
||||
ce.Value = request.Settings.FFprobePath;
|
||||
@@ -48,7 +97,7 @@ namespace ErsatzTV.Application.FFmpegProfiles.Commands
|
||||
_configElementRepository.Add(ce);
|
||||
});
|
||||
|
||||
defaultFFmpegProfileId.Match(
|
||||
await _configElementRepository.Get(ConfigElementKey.FFmpegDefaultProfileId).Match(
|
||||
ce =>
|
||||
{
|
||||
ce.Value = request.Settings.DefaultFFmpegProfileId.ToString();
|
||||
@@ -64,7 +113,28 @@ namespace ErsatzTV.Application.FFmpegProfiles.Commands
|
||||
_configElementRepository.Add(ce);
|
||||
});
|
||||
|
||||
return Unit.Value;
|
||||
await _configElementRepository.Get(ConfigElementKey.FFmpegSaveReports).Match(
|
||||
ce =>
|
||||
{
|
||||
ce.Value = request.Settings.SaveReports.ToString();
|
||||
_configElementRepository.Update(ce);
|
||||
},
|
||||
() =>
|
||||
{
|
||||
var ce = new ConfigElement
|
||||
{
|
||||
Key = ConfigElementKey.FFmpegSaveReports.Key,
|
||||
Value = request.Settings.SaveReports.ToString()
|
||||
};
|
||||
_configElementRepository.Add(ce);
|
||||
});
|
||||
|
||||
if (request.Settings.SaveReports && !Directory.Exists(FileSystemLayout.FFmpegReportsFolder))
|
||||
{
|
||||
Directory.CreateDirectory(FileSystemLayout.FFmpegReportsFolder);
|
||||
}
|
||||
|
||||
return Unit.Default;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,5 +5,6 @@
|
||||
public string FFmpegPath { get; set; }
|
||||
public string FFprobePath { get; set; }
|
||||
public int DefaultFFmpegProfileId { get; set; }
|
||||
public bool SaveReports { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,12 +22,15 @@ namespace ErsatzTV.Application.FFmpegProfiles.Queries
|
||||
Option<string> ffprobePath = await _configElementRepository.GetValue<string>(ConfigElementKey.FFprobePath);
|
||||
Option<int> defaultFFmpegProfileId =
|
||||
await _configElementRepository.GetValue<int>(ConfigElementKey.FFmpegDefaultProfileId);
|
||||
Option<bool> saveReports =
|
||||
await _configElementRepository.GetValue<bool>(ConfigElementKey.FFmpegSaveReports);
|
||||
|
||||
return new FFmpegSettingsViewModel
|
||||
{
|
||||
FFmpegPath = ffmpegPath.IfNone(string.Empty),
|
||||
FFprobePath = ffprobePath.IfNone(string.Empty),
|
||||
DefaultFFmpegProfileId = defaultFFmpegProfileId.IfNone(0)
|
||||
DefaultFFmpegProfileId = defaultFFmpegProfileId.IfNone(0),
|
||||
SaveReports = saveReports.IfNone(false)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
using System.Threading;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using LanguageExt;
|
||||
|
||||
namespace ErsatzTV.Application.Libraries.Commands
|
||||
@@ -11,9 +13,13 @@ namespace ErsatzTV.Application.Libraries.Commands
|
||||
DeleteLocalLibraryPathHandler : MediatR.IRequestHandler<DeleteLocalLibraryPath, Either<BaseError, Unit>>
|
||||
{
|
||||
private readonly ILibraryRepository _libraryRepository;
|
||||
private readonly ISearchIndex _searchIndex;
|
||||
|
||||
public DeleteLocalLibraryPathHandler(ILibraryRepository libraryRepository) =>
|
||||
public DeleteLocalLibraryPathHandler(ILibraryRepository libraryRepository, ISearchIndex searchIndex)
|
||||
{
|
||||
_libraryRepository = libraryRepository;
|
||||
_searchIndex = searchIndex;
|
||||
}
|
||||
|
||||
public Task<Either<BaseError, Unit>> Handle(
|
||||
DeleteLocalLibraryPath request,
|
||||
@@ -22,8 +28,13 @@ namespace ErsatzTV.Application.Libraries.Commands
|
||||
.MapT(DoDeletion)
|
||||
.Bind(t => t.ToEitherAsync());
|
||||
|
||||
private Task<Unit> DoDeletion(LibraryPath libraryPath) =>
|
||||
_libraryRepository.DeleteLocalPath(libraryPath.Id).ToUnit();
|
||||
private async Task<Unit> DoDeletion(LibraryPath libraryPath)
|
||||
{
|
||||
List<int> ids = await _libraryRepository.GetMediaIdsByLocalPath(libraryPath.Id);
|
||||
await _searchIndex.RemoveItems(ids);
|
||||
await _libraryRepository.DeleteLocalPath(libraryPath.Id);
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
private async Task<Validation<BaseError, LibraryPath>> MediaSourceMustExist(DeleteLocalLibraryPath request) =>
|
||||
(await _libraryRepository.GetPath(request.LocalLibraryPathId))
|
||||
|
||||
@@ -7,5 +7,8 @@ namespace ErsatzTV.Application.MediaCards
|
||||
List<MovieCardViewModel> MovieCards,
|
||||
List<TelevisionShowCardViewModel> ShowCards,
|
||||
List<TelevisionSeasonCardViewModel> SeasonCards,
|
||||
List<TelevisionEpisodeCardViewModel> EpisodeCards);
|
||||
List<TelevisionEpisodeCardViewModel> EpisodeCards)
|
||||
{
|
||||
public bool UseCustomPlaybackOrder { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ namespace ErsatzTV.Application.MediaCards
|
||||
|
||||
internal static TelevisionSeasonCardViewModel ProjectToViewModel(Season season) =>
|
||||
new(
|
||||
season.Show.ShowMetadata.HeadOrNone().Map(m => m.Title).IfNone(string.Empty),
|
||||
season.Show.ShowMetadata.HeadOrNone().Match(m => m.Title ?? string.Empty, () => string.Empty),
|
||||
season.Id,
|
||||
season.SeasonNumber,
|
||||
GetSeasonName(season.SeasonNumber),
|
||||
@@ -32,12 +32,16 @@ namespace ErsatzTV.Application.MediaCards
|
||||
new(
|
||||
episodeMetadata.EpisodeId,
|
||||
episodeMetadata.ReleaseDate ?? DateTime.MinValue,
|
||||
episodeMetadata.Episode.Season.Show.ShowMetadata.HeadOrNone().Map(m => m.Title).IfNone(string.Empty),
|
||||
episodeMetadata.Episode.Season.Show.ShowMetadata.HeadOrNone().Match(
|
||||
m => m.Title ?? string.Empty,
|
||||
() => string.Empty),
|
||||
episodeMetadata.Episode.Season.ShowId,
|
||||
episodeMetadata.Episode.SeasonId,
|
||||
episodeMetadata.Episode.EpisodeNumber,
|
||||
episodeMetadata.Title,
|
||||
episodeMetadata.Episode.EpisodeMetadata.HeadOrNone().Map(em => em.Plot).IfNone(string.Empty),
|
||||
episodeMetadata.Episode.EpisodeMetadata.HeadOrNone().Match(
|
||||
em => em.Plot ?? string.Empty,
|
||||
() => string.Empty),
|
||||
GetThumbnail(episodeMetadata));
|
||||
|
||||
internal static MovieCardViewModel ProjectToViewModel(MovieMetadata movieMetadata) =>
|
||||
@@ -52,11 +56,20 @@ namespace ErsatzTV.Application.MediaCards
|
||||
ProjectToViewModel(Collection collection) =>
|
||||
new(
|
||||
collection.Name,
|
||||
collection.MediaItems.OfType<Movie>().Map(m => ProjectToViewModel(m.MovieMetadata.Head())).ToList(),
|
||||
collection.MediaItems.OfType<Movie>().Map(
|
||||
m => ProjectToViewModel(m.MovieMetadata.Head()) with
|
||||
{
|
||||
CustomIndex = GetCustomIndex(collection, m.Id)
|
||||
}).ToList(),
|
||||
collection.MediaItems.OfType<Show>().Map(s => ProjectToViewModel(s.ShowMetadata.Head())).ToList(),
|
||||
collection.MediaItems.OfType<Season>().Map(ProjectToViewModel).ToList(),
|
||||
collection.MediaItems.OfType<Episode>().Map(e => ProjectToViewModel(e.EpisodeMetadata.Head()))
|
||||
.ToList());
|
||||
.ToList()) { UseCustomPlaybackOrder = collection.UseCustomPlaybackOrder };
|
||||
|
||||
private static int GetCustomIndex(Collection collection, int mediaItemId) =>
|
||||
Optional(collection.CollectionItems.Find(ci => ci.MediaItemId == mediaItemId))
|
||||
.Map(ci => ci.CustomIndex ?? 0)
|
||||
.IfNone(0);
|
||||
|
||||
internal static SearchCardResultsViewModel ProjectToSearchResults(List<MediaItem> items) =>
|
||||
new(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace ErsatzTV.Application.MediaCards
|
||||
{
|
||||
public record MediaCardViewModel(string Title, string Subtitle, string SortTitle, string Poster);
|
||||
public record MediaCardViewModel(int MediaItemId, string Title, string Subtitle, string SortTitle, string Poster);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
using System.Collections.Generic;
|
||||
using ErsatzTV.Core.Search;
|
||||
using LanguageExt;
|
||||
|
||||
namespace ErsatzTV.Application.MediaCards
|
||||
{
|
||||
public record MovieCardResultsViewModel(int Count, List<MovieCardViewModel> Cards);
|
||||
public record MovieCardResultsViewModel(int Count, List<MovieCardViewModel> Cards, Option<SearchPageMap> PageMap);
|
||||
}
|
||||
|
||||
@@ -2,10 +2,12 @@
|
||||
{
|
||||
public record MovieCardViewModel
|
||||
(int MovieId, string Title, string Subtitle, string SortTitle, string Poster) : MediaCardViewModel(
|
||||
MovieId,
|
||||
Title,
|
||||
Subtitle,
|
||||
SortTitle,
|
||||
Poster)
|
||||
{
|
||||
public int CustomIndex { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
using MediatR;
|
||||
|
||||
namespace ErsatzTV.Application.MediaCards.Queries
|
||||
{
|
||||
public record GetMovieCards(int PageNumber, int PageSize) : IRequest<MovieCardResultsViewModel>;
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
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;
|
||||
|
||||
namespace ErsatzTV.Application.MediaCards.Queries
|
||||
{
|
||||
public class
|
||||
GetMovieCardsHandler : IRequestHandler<GetMovieCards, MovieCardResultsViewModel>
|
||||
{
|
||||
private readonly IMovieRepository _movieRepository;
|
||||
|
||||
public GetMovieCardsHandler(IMovieRepository movieRepository) => _movieRepository = movieRepository;
|
||||
|
||||
public async Task<MovieCardResultsViewModel> Handle(GetMovieCards request, CancellationToken cancellationToken)
|
||||
{
|
||||
int count = await _movieRepository.GetMovieCount();
|
||||
|
||||
List<MovieCardViewModel> results = await _movieRepository
|
||||
.GetPagedMovies(request.PageNumber, request.PageSize)
|
||||
.Map(list => list.Map(ProjectToViewModel).ToList());
|
||||
|
||||
return new MovieCardResultsViewModel(count, results);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
using ErsatzTV.Core;
|
||||
using LanguageExt;
|
||||
using MediatR;
|
||||
|
||||
namespace ErsatzTV.Application.MediaCards.Queries
|
||||
{
|
||||
public record GetSearchCards(string Query) : IRequest<Either<BaseError, SearchCardResultsViewModel>>;
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core;
|
||||
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 GetSearchCardsHandler : IRequestHandler<GetSearchCards, Either<BaseError, SearchCardResultsViewModel>>
|
||||
{
|
||||
private readonly ISearchRepository _searchRepository;
|
||||
|
||||
public GetSearchCardsHandler(ISearchRepository searchRepository) => _searchRepository = searchRepository;
|
||||
|
||||
public Task<Either<BaseError, SearchCardResultsViewModel>> Handle(
|
||||
GetSearchCards request,
|
||||
CancellationToken cancellationToken) =>
|
||||
request.Query.Split(":").Head() switch
|
||||
{
|
||||
"genre" => GenreSearch(request.Query.Replace("genre:", string.Empty)),
|
||||
"tag" => TagSearch(request.Query.Replace("tag:", string.Empty)),
|
||||
_ => TitleSearch(request.Query)
|
||||
};
|
||||
|
||||
private Task<Either<BaseError, SearchCardResultsViewModel>> TitleSearch(string query) =>
|
||||
Try(_searchRepository.SearchMediaItemsByTitle(query)).Sequence()
|
||||
.Map(ProjectToSearchResults)
|
||||
.Map(t => t.ToEither(ex => BaseError.New($"Failed to search: {ex.Message}")));
|
||||
|
||||
private Task<Either<BaseError, SearchCardResultsViewModel>> GenreSearch(string query) =>
|
||||
Try(_searchRepository.SearchMediaItemsByGenre(query)).Sequence()
|
||||
.Map(ProjectToSearchResults)
|
||||
.Map(t => t.ToEither(ex => BaseError.New($"Failed to search: {ex.Message}")));
|
||||
|
||||
private Task<Either<BaseError, SearchCardResultsViewModel>> TagSearch(string query) =>
|
||||
Try(_searchRepository.SearchMediaItemsByTag(query)).Sequence()
|
||||
.Map(ProjectToSearchResults)
|
||||
.Map(t => t.ToEither(ex => BaseError.New($"Failed to search: {ex.Message}")));
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
using MediatR;
|
||||
|
||||
namespace ErsatzTV.Application.MediaCards.Queries
|
||||
{
|
||||
public record GetTelevisionShowCards(int PageNumber, int PageSize) : IRequest<TelevisionShowCardResultsViewModel>;
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
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;
|
||||
|
||||
namespace ErsatzTV.Application.MediaCards.Queries
|
||||
{
|
||||
public class
|
||||
GetTelevisionShowCardsHandler : IRequestHandler<GetTelevisionShowCards, TelevisionShowCardResultsViewModel>
|
||||
{
|
||||
private readonly ITelevisionRepository _televisionRepository;
|
||||
|
||||
public GetTelevisionShowCardsHandler(ITelevisionRepository televisionRepository) =>
|
||||
_televisionRepository = televisionRepository;
|
||||
|
||||
public async Task<TelevisionShowCardResultsViewModel> Handle(
|
||||
GetTelevisionShowCards request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
int count = await _televisionRepository.GetShowCount();
|
||||
|
||||
List<TelevisionShowCardViewModel> results = await _televisionRepository
|
||||
.GetPagedShows(request.PageNumber, request.PageSize)
|
||||
.Map(list => list.Map(ProjectToViewModel).ToList());
|
||||
|
||||
return new TelevisionShowCardResultsViewModel(count, results);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ namespace ErsatzTV.Application.MediaCards
|
||||
string Title,
|
||||
string Plot,
|
||||
string Poster) : MediaCardViewModel(
|
||||
EpisodeId,
|
||||
Title,
|
||||
$"Episode {Episode}",
|
||||
$"Episode {Episode}",
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
string SortTitle,
|
||||
string Poster,
|
||||
string Placeholder) : MediaCardViewModel(
|
||||
TelevisionSeasonId,
|
||||
Title,
|
||||
Subtitle,
|
||||
SortTitle,
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
using System.Collections.Generic;
|
||||
using ErsatzTV.Core.Search;
|
||||
using LanguageExt;
|
||||
|
||||
namespace ErsatzTV.Application.MediaCards
|
||||
{
|
||||
public record TelevisionShowCardResultsViewModel(int Count, List<TelevisionShowCardViewModel> Cards);
|
||||
public record TelevisionShowCardResultsViewModel(
|
||||
int Count,
|
||||
List<TelevisionShowCardViewModel> Cards,
|
||||
Option<SearchPageMap> PageMap);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
{
|
||||
public record TelevisionShowCardViewModel
|
||||
(int TelevisionShowId, string Title, string Subtitle, string SortTitle, string Poster) : MediaCardViewModel(
|
||||
TelevisionShowId,
|
||||
Title,
|
||||
Subtitle,
|
||||
SortTitle,
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
using ErsatzTV.Core;
|
||||
using LanguageExt;
|
||||
using static LanguageExt.Prelude;
|
||||
|
||||
namespace ErsatzTV.Application.MediaCollections.Commands
|
||||
{
|
||||
public record UpdateCollection
|
||||
(int CollectionId, string Name) : MediatR.IRequest<Either<BaseError, Unit>>;
|
||||
(int CollectionId, string Name) : MediatR.IRequest<Either<BaseError, Unit>>
|
||||
{
|
||||
public Option<bool> UseCustomPlaybackOrder { get; set; } = None;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
using System.Collections.Generic;
|
||||
using ErsatzTV.Core;
|
||||
using LanguageExt;
|
||||
|
||||
namespace ErsatzTV.Application.MediaCollections.Commands
|
||||
{
|
||||
public record UpdateCollectionCustomOrder
|
||||
(
|
||||
int CollectionId,
|
||||
List<MediaItemCustomOrder> MediaItemCustomOrders) : MediatR.IRequest<Either<BaseError, Unit>>;
|
||||
|
||||
public record MediaItemCustomOrder(int MediaItemId, int CustomIndex);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
using System.Linq;
|
||||
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
|
||||
UpdateCollectionCustomOrderHandler : MediatR.IRequestHandler<UpdateCollectionCustomOrder,
|
||||
Either<BaseError, Unit>>
|
||||
{
|
||||
private readonly ChannelWriter<IBackgroundServiceRequest> _channel;
|
||||
private readonly IMediaCollectionRepository _mediaCollectionRepository;
|
||||
|
||||
public UpdateCollectionCustomOrderHandler(
|
||||
IMediaCollectionRepository mediaCollectionRepository,
|
||||
ChannelWriter<IBackgroundServiceRequest> channel)
|
||||
{
|
||||
_mediaCollectionRepository = mediaCollectionRepository;
|
||||
_channel = channel;
|
||||
}
|
||||
|
||||
public Task<Either<BaseError, Unit>> Handle(
|
||||
UpdateCollectionCustomOrder request,
|
||||
CancellationToken cancellationToken) =>
|
||||
Validate(request)
|
||||
.MapT(c => ApplyUpdateRequest(c, request))
|
||||
.Bind(v => v.ToEitherAsync());
|
||||
|
||||
private async Task<Unit> ApplyUpdateRequest(Collection c, UpdateCollectionCustomOrder request)
|
||||
{
|
||||
foreach (MediaItemCustomOrder updateItem in request.MediaItemCustomOrders)
|
||||
{
|
||||
Option<CollectionItem> maybeCollectionItem =
|
||||
c.CollectionItems.FirstOrDefault(ci => ci.MediaItemId == updateItem.MediaItemId);
|
||||
|
||||
maybeCollectionItem.IfSome(ci => ci.CustomIndex = updateItem.CustomIndex);
|
||||
}
|
||||
|
||||
if (await _mediaCollectionRepository.Update(c))
|
||||
{
|
||||
// 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 Task<Validation<BaseError, Collection>> Validate(UpdateCollectionCustomOrder request) =>
|
||||
CollectionMustExist(request);
|
||||
|
||||
private Task<Validation<BaseError, Collection>> CollectionMustExist(
|
||||
UpdateCollectionCustomOrder request) =>
|
||||
_mediaCollectionRepository.Get(request.CollectionId)
|
||||
.Map(v => v.ToValidation<BaseError>("Collection does not exist."));
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
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;
|
||||
@@ -9,10 +11,16 @@ namespace ErsatzTV.Application.MediaCollections.Commands
|
||||
{
|
||||
public class UpdateCollectionHandler : MediatR.IRequestHandler<UpdateCollection, Either<BaseError, Unit>>
|
||||
{
|
||||
private readonly ChannelWriter<IBackgroundServiceRequest> _channel;
|
||||
private readonly IMediaCollectionRepository _mediaCollectionRepository;
|
||||
|
||||
public UpdateCollectionHandler(IMediaCollectionRepository mediaCollectionRepository) =>
|
||||
public UpdateCollectionHandler(
|
||||
IMediaCollectionRepository mediaCollectionRepository,
|
||||
ChannelWriter<IBackgroundServiceRequest> channel)
|
||||
{
|
||||
_mediaCollectionRepository = mediaCollectionRepository;
|
||||
_channel = channel;
|
||||
}
|
||||
|
||||
public Task<Either<BaseError, Unit>> Handle(
|
||||
UpdateCollection request,
|
||||
@@ -21,10 +29,21 @@ namespace ErsatzTV.Application.MediaCollections.Commands
|
||||
.MapT(c => ApplyUpdateRequest(c, request))
|
||||
.Bind(v => v.ToEitherAsync());
|
||||
|
||||
private async Task<Unit> ApplyUpdateRequest(Collection c, UpdateCollection update)
|
||||
private async Task<Unit> ApplyUpdateRequest(Collection c, UpdateCollection request)
|
||||
{
|
||||
c.Name = update.Name;
|
||||
await _mediaCollectionRepository.Update(c);
|
||||
c.Name = request.Name;
|
||||
request.UseCustomPlaybackOrder.IfSome(
|
||||
useCustomPlaybackOrder => c.UseCustomPlaybackOrder = useCustomPlaybackOrder);
|
||||
if (await _mediaCollectionRepository.Update(c) && request.UseCustomPlaybackOrder.IsSome)
|
||||
{
|
||||
// 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;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace ErsatzTV.Application.MediaCollections
|
||||
{
|
||||
public record MediaCollectionViewModel(int Id, string Name) : MediaCardViewModel(
|
||||
Id,
|
||||
Name,
|
||||
string.Empty,
|
||||
Name,
|
||||
|
||||
@@ -16,7 +16,8 @@ namespace ErsatzTV.Application.Movies
|
||||
Artwork(metadata, ArtworkKind.Poster),
|
||||
Artwork(metadata, ArtworkKind.FanArt),
|
||||
metadata.Genres.Map(g => g.Name).ToList(),
|
||||
metadata.Tags.Map(t => t.Name).ToList());
|
||||
metadata.Tags.Map(t => t.Name).ToList(),
|
||||
metadata.Studios.Map(s => s.Name).ToList());
|
||||
}
|
||||
|
||||
private static string Artwork(Metadata metadata, ArtworkKind artworkKind) =>
|
||||
|
||||
@@ -9,5 +9,6 @@ namespace ErsatzTV.Application.Movies
|
||||
string Poster,
|
||||
string FanArt,
|
||||
List<string> Genres,
|
||||
List<string> Tags);
|
||||
List<string> Tags,
|
||||
List<string> Studios);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
using ErsatzTV.Core;
|
||||
using LanguageExt;
|
||||
|
||||
namespace ErsatzTV.Application.Plex.Commands
|
||||
{
|
||||
public record SignOutOfPlex : MediatR.IRequest<Either<BaseError, Unit>>;
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Interfaces.Locking;
|
||||
using ErsatzTV.Core.Interfaces.Plex;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using LanguageExt;
|
||||
|
||||
namespace ErsatzTV.Application.Plex.Commands
|
||||
{
|
||||
public class SignOutOfPlexHandler : MediatR.IRequestHandler<SignOutOfPlex, Either<BaseError, Unit>>
|
||||
{
|
||||
private readonly IEntityLocker _entityLocker;
|
||||
private readonly IMediaSourceRepository _mediaSourceRepository;
|
||||
private readonly IPlexSecretStore _plexSecretStore;
|
||||
private readonly ISearchIndex _searchIndex;
|
||||
|
||||
public SignOutOfPlexHandler(
|
||||
IMediaSourceRepository mediaSourceRepository,
|
||||
IPlexSecretStore plexSecretStore,
|
||||
IEntityLocker entityLocker,
|
||||
ISearchIndex searchIndex)
|
||||
{
|
||||
_mediaSourceRepository = mediaSourceRepository;
|
||||
_plexSecretStore = plexSecretStore;
|
||||
_entityLocker = entityLocker;
|
||||
_searchIndex = searchIndex;
|
||||
}
|
||||
|
||||
public async Task<Either<BaseError, Unit>> Handle(SignOutOfPlex request, CancellationToken cancellationToken)
|
||||
{
|
||||
List<int> ids = await _mediaSourceRepository.DeleteAllPlex();
|
||||
await _searchIndex.RemoveItems(ids);
|
||||
await _plexSecretStore.DeleteAll();
|
||||
_entityLocker.UnlockPlex();
|
||||
|
||||
return Unit.Default;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,5 +3,6 @@ using LanguageExt;
|
||||
|
||||
namespace ErsatzTV.Application.Plex.Commands
|
||||
{
|
||||
public record SynchronizePlexLibraries(int PlexMediaSourceId) : MediatR.IRequest<Either<BaseError, Unit>>;
|
||||
public record SynchronizePlexLibraries(int PlexMediaSourceId) : MediatR.IRequest<Either<BaseError, Unit>>,
|
||||
IPlexBackgroundServiceRequest;
|
||||
}
|
||||
|
||||
@@ -78,10 +78,10 @@ namespace ErsatzTV.Application.Plex.Commands
|
||||
var existing = connectionParameters.PlexMediaSource.Libraries.OfType<PlexLibrary>().ToList();
|
||||
var toAdd = libraries.Filter(library => existing.All(l => l.Key != library.Key)).ToList();
|
||||
var toRemove = existing.Filter(library => libraries.All(l => l.Key != library.Key)).ToList();
|
||||
connectionParameters.PlexMediaSource.Libraries.AddRange(toAdd);
|
||||
toRemove.ForEach(c => connectionParameters.PlexMediaSource.Libraries.Remove(c));
|
||||
|
||||
return _mediaSourceRepository.Update(connectionParameters.PlexMediaSource);
|
||||
return _mediaSourceRepository.UpdateLibraries(
|
||||
connectionParameters.PlexMediaSource.Id,
|
||||
toAdd,
|
||||
toRemove);
|
||||
},
|
||||
error =>
|
||||
{
|
||||
|
||||
@@ -24,17 +24,20 @@ namespace ErsatzTV.Application.Plex.Commands
|
||||
private readonly IMediaSourceRepository _mediaSourceRepository;
|
||||
private readonly IPlexMovieLibraryScanner _plexMovieLibraryScanner;
|
||||
private readonly IPlexSecretStore _plexSecretStore;
|
||||
private readonly IPlexTelevisionLibraryScanner _plexTelevisionLibraryScanner;
|
||||
|
||||
public SynchronizePlexLibraryByIdHandler(
|
||||
IMediaSourceRepository mediaSourceRepository,
|
||||
IPlexSecretStore plexSecretStore,
|
||||
IPlexMovieLibraryScanner plexMovieLibraryScanner,
|
||||
IPlexTelevisionLibraryScanner plexTelevisionLibraryScanner,
|
||||
IEntityLocker entityLocker,
|
||||
ILogger<SynchronizePlexLibraryByIdHandler> logger)
|
||||
{
|
||||
_mediaSourceRepository = mediaSourceRepository;
|
||||
_plexSecretStore = plexSecretStore;
|
||||
_plexMovieLibraryScanner = plexMovieLibraryScanner;
|
||||
_plexTelevisionLibraryScanner = plexTelevisionLibraryScanner;
|
||||
_entityLocker = entityLocker;
|
||||
_logger = logger;
|
||||
}
|
||||
@@ -67,8 +70,10 @@ namespace ErsatzTV.Application.Plex.Commands
|
||||
parameters.Library);
|
||||
break;
|
||||
case LibraryMediaKind.Shows:
|
||||
// TODO: plex tv scanner
|
||||
// await _televisionFolderScanner.ScanFolder(parameters.LocalMediaSource, parameters.FFprobePath);
|
||||
await _plexTelevisionLibraryScanner.ScanLibrary(
|
||||
parameters.ConnectionParameters.ActiveConnection,
|
||||
parameters.ConnectionParameters.PlexServerAuthToken,
|
||||
parameters.Library);
|
||||
break;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Channels;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Locking;
|
||||
using ErsatzTV.Core.Interfaces.Plex;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using LanguageExt;
|
||||
@@ -15,15 +17,21 @@ namespace ErsatzTV.Application.Plex.Commands
|
||||
SynchronizePlexMediaSourcesHandler : IRequestHandler<SynchronizePlexMediaSources,
|
||||
Either<BaseError, List<PlexMediaSource>>>
|
||||
{
|
||||
private readonly ChannelWriter<IPlexBackgroundServiceRequest> _channel;
|
||||
private readonly IEntityLocker _entityLocker;
|
||||
private readonly IMediaSourceRepository _mediaSourceRepository;
|
||||
private readonly IPlexTvApiClient _plexTvApiClient;
|
||||
|
||||
public SynchronizePlexMediaSourcesHandler(
|
||||
IMediaSourceRepository mediaSourceRepository,
|
||||
IPlexTvApiClient plexTvApiClient)
|
||||
IPlexTvApiClient plexTvApiClient,
|
||||
ChannelWriter<IPlexBackgroundServiceRequest> channel,
|
||||
IEntityLocker entityLocker)
|
||||
{
|
||||
_mediaSourceRepository = mediaSourceRepository;
|
||||
_plexTvApiClient = plexTvApiClient;
|
||||
_channel = channel;
|
||||
_entityLocker = entityLocker;
|
||||
}
|
||||
|
||||
public Task<Either<BaseError, List<PlexMediaSource>>> Handle(
|
||||
@@ -39,6 +47,13 @@ namespace ErsatzTV.Application.Plex.Commands
|
||||
await SynchronizeServer(allExisting, server);
|
||||
}
|
||||
|
||||
foreach (PlexMediaSource mediaSource in await _mediaSourceRepository.GetAllPlex())
|
||||
{
|
||||
await _channel.WriteAsync(new SynchronizePlexLibraries(mediaSource.Id));
|
||||
}
|
||||
|
||||
_entityLocker.UnlockPlex();
|
||||
|
||||
return allExisting;
|
||||
}
|
||||
|
||||
@@ -51,23 +66,20 @@ namespace ErsatzTV.Application.Plex.Commands
|
||||
{
|
||||
existing.ProductVersion = server.ProductVersion;
|
||||
existing.ServerName = server.ServerName;
|
||||
MergeConnections(existing.Connections, server.Connections);
|
||||
if (existing.Connections.Any() && existing.Connections.All(c => !c.IsActive))
|
||||
{
|
||||
existing.Connections.Head().IsActive = true;
|
||||
}
|
||||
|
||||
return _mediaSourceRepository.Update(existing);
|
||||
var toAdd = server.Connections
|
||||
.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);
|
||||
},
|
||||
async () =>
|
||||
{
|
||||
await _mediaSourceRepository.Add(server);
|
||||
if (server.Connections.Any())
|
||||
{
|
||||
server.Connections.Head().IsActive = true;
|
||||
}
|
||||
|
||||
await _mediaSourceRepository.Update(server);
|
||||
await _mediaSourceRepository.Add(server);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using LanguageExt;
|
||||
|
||||
namespace ErsatzTV.Application.Plex.Commands
|
||||
@@ -13,16 +14,23 @@ namespace ErsatzTV.Application.Plex.Commands
|
||||
Either<BaseError, Unit>>
|
||||
{
|
||||
private readonly IMediaSourceRepository _mediaSourceRepository;
|
||||
private readonly ISearchIndex _searchIndex;
|
||||
|
||||
public UpdatePlexLibraryPreferencesHandler(IMediaSourceRepository mediaSourceRepository) =>
|
||||
public UpdatePlexLibraryPreferencesHandler(
|
||||
IMediaSourceRepository mediaSourceRepository,
|
||||
ISearchIndex searchIndex)
|
||||
{
|
||||
_mediaSourceRepository = mediaSourceRepository;
|
||||
_searchIndex = searchIndex;
|
||||
}
|
||||
|
||||
public async Task<Either<BaseError, Unit>> Handle(
|
||||
UpdatePlexLibraryPreferences request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var toDisable = request.Preferences.Filter(p => p.ShouldSyncItems == false).Map(p => p.Id).ToList();
|
||||
await _mediaSourceRepository.DisablePlexLibrarySync(toDisable);
|
||||
List<int> ids = await _mediaSourceRepository.DisablePlexLibrarySync(toDisable);
|
||||
await _searchIndex.RemoveItems(ids);
|
||||
|
||||
IEnumerable<int> toEnable = request.Preferences.Filter(p => p.ShouldSyncItems).Map(p => p.Id);
|
||||
await _mediaSourceRepository.EnablePlexLibrarySync(toEnable);
|
||||
|
||||
@@ -6,7 +6,6 @@ using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using LanguageExt;
|
||||
using static LanguageExt.Prelude;
|
||||
|
||||
namespace ErsatzTV.Application.Plex.Commands
|
||||
{
|
||||
@@ -35,20 +34,7 @@ namespace ErsatzTV.Application.Plex.Commands
|
||||
var toRemove = plexMediaSource.PathReplacements.Filter(r => incoming.All(pr => pr.Id != r.Id)).ToList();
|
||||
var toUpdate = incoming.Except(toAdd).ToList();
|
||||
|
||||
plexMediaSource.PathReplacements.AddRange(toAdd);
|
||||
toRemove.ForEach(pr => plexMediaSource.PathReplacements.Remove(pr));
|
||||
foreach (PlexPathReplacement pathReplacement in toUpdate)
|
||||
{
|
||||
Optional(plexMediaSource.PathReplacements.SingleOrDefault(pr => pr.Id == pathReplacement.Id))
|
||||
.IfSome(
|
||||
pr =>
|
||||
{
|
||||
pr.PlexPath = pathReplacement.PlexPath;
|
||||
pr.LocalPath = pathReplacement.LocalPath;
|
||||
});
|
||||
}
|
||||
|
||||
return _mediaSourceRepository.Update(plexMediaSource).ToUnit();
|
||||
return _mediaSourceRepository.UpdatePathReplacements(plexMediaSource.Id, toAdd, toUpdate, toRemove);
|
||||
}
|
||||
|
||||
private static PlexPathReplacement Project(PlexPathReplacementItem vm) =>
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
using LanguageExt;
|
||||
|
||||
namespace ErsatzTV.Application.Search.Commands
|
||||
{
|
||||
public record RebuildSearchIndex : MediatR.IRequest<Unit>, IBackgroundServiceRequest;
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using LanguageExt;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ErsatzTV.Application.Search.Commands
|
||||
{
|
||||
public class RebuildSearchIndexHandler : MediatR.IRequestHandler<RebuildSearchIndex, Unit>
|
||||
{
|
||||
private readonly IConfigElementRepository _configElementRepository;
|
||||
private readonly ILogger<RebuildSearchIndexHandler> _logger;
|
||||
private readonly ISearchIndex _searchIndex;
|
||||
private readonly ISearchRepository _searchRepository;
|
||||
|
||||
public RebuildSearchIndexHandler(
|
||||
ISearchIndex searchIndex,
|
||||
ISearchRepository searchRepository,
|
||||
IConfigElementRepository configElementRepository,
|
||||
ILogger<RebuildSearchIndexHandler> logger)
|
||||
{
|
||||
_searchIndex = searchIndex;
|
||||
_logger = logger;
|
||||
_searchRepository = searchRepository;
|
||||
_configElementRepository = configElementRepository;
|
||||
}
|
||||
|
||||
public async Task<Unit> Handle(RebuildSearchIndex request, CancellationToken cancellationToken)
|
||||
{
|
||||
bool indexFolderExists = Directory.Exists(FileSystemLayout.SearchIndexFolder);
|
||||
|
||||
if (!indexFolderExists ||
|
||||
await _configElementRepository.GetValue<int>(ConfigElementKey.SearchIndexVersion) <
|
||||
_searchIndex.Version)
|
||||
{
|
||||
_logger.LogDebug("Migrating search index to version {Version}", _searchIndex.Version);
|
||||
|
||||
List<int> itemIds = await _searchRepository.GetItemIdsToIndex();
|
||||
await _searchIndex.Rebuild(itemIds);
|
||||
|
||||
Option<ConfigElement> maybeVersion =
|
||||
await _configElementRepository.Get(ConfigElementKey.SearchIndexVersion);
|
||||
await maybeVersion.Match(
|
||||
version =>
|
||||
{
|
||||
version.Value = _searchIndex.Version.ToString();
|
||||
return _configElementRepository.Update(version);
|
||||
},
|
||||
() =>
|
||||
{
|
||||
var configElement = new ConfigElement
|
||||
{
|
||||
Key = ConfigElementKey.SearchIndexVersion.Key,
|
||||
Value = _searchIndex.Version.ToString()
|
||||
};
|
||||
return _configElementRepository.Add(configElement);
|
||||
});
|
||||
|
||||
_logger.LogDebug("Done migrating search index");
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogDebug("Search index is already version {Version}", _searchIndex.Version);
|
||||
}
|
||||
|
||||
return Unit.Default;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
using ErsatzTV.Core.Search;
|
||||
using MediatR;
|
||||
|
||||
namespace ErsatzTV.Application.Search.Queries
|
||||
{
|
||||
public record QuerySearchIndex(string Query) : IRequest<SearchResult>;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
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,8 @@
|
||||
using ErsatzTV.Application.MediaCards;
|
||||
using MediatR;
|
||||
|
||||
namespace ErsatzTV.Application.Search.Queries
|
||||
{
|
||||
public record QuerySearchIndexMovies
|
||||
(string Query, int PageNumber, int PageSize) : IRequest<MovieCardResultsViewModel>;
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
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 QuerySearchIndexMoviesHandler : IRequestHandler<QuerySearchIndexMovies, MovieCardResultsViewModel>
|
||||
{
|
||||
private readonly IMovieRepository _movieRepository;
|
||||
private readonly ISearchIndex _searchIndex;
|
||||
|
||||
public QuerySearchIndexMoviesHandler(ISearchIndex searchIndex, IMovieRepository movieRepository)
|
||||
{
|
||||
_searchIndex = searchIndex;
|
||||
_movieRepository = movieRepository;
|
||||
}
|
||||
|
||||
public async Task<MovieCardResultsViewModel> Handle(
|
||||
QuerySearchIndexMovies request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
SearchResult searchResult = await _searchIndex.Search(
|
||||
request.Query,
|
||||
(request.PageNumber - 1) * request.PageSize,
|
||||
request.PageSize);
|
||||
|
||||
List<MovieCardViewModel> items = await _movieRepository
|
||||
.GetMoviesForCards(searchResult.Items.Map(i => i.Id).ToList())
|
||||
.Map(list => list.Map(ProjectToViewModel).ToList());
|
||||
|
||||
return new MovieCardResultsViewModel(searchResult.TotalCount, items, searchResult.PageMap);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
using ErsatzTV.Application.MediaCards;
|
||||
using MediatR;
|
||||
|
||||
namespace ErsatzTV.Application.Search.Queries
|
||||
{
|
||||
public record QuerySearchIndexShows
|
||||
(string Query, int PageNumber, int PageSize) : IRequest<TelevisionShowCardResultsViewModel>;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
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
|
||||
QuerySearchIndexShowsHandler : IRequestHandler<QuerySearchIndexShows, TelevisionShowCardResultsViewModel>
|
||||
{
|
||||
private readonly ISearchIndex _searchIndex;
|
||||
private readonly ITelevisionRepository _televisionRepository;
|
||||
|
||||
public QuerySearchIndexShowsHandler(ISearchIndex searchIndex, ITelevisionRepository televisionRepository)
|
||||
{
|
||||
_searchIndex = searchIndex;
|
||||
_televisionRepository = televisionRepository;
|
||||
}
|
||||
|
||||
public async Task<TelevisionShowCardResultsViewModel> Handle(
|
||||
QuerySearchIndexShows request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
SearchResult searchResult = await _searchIndex.Search(
|
||||
request.Query,
|
||||
(request.PageNumber - 1) * request.PageSize,
|
||||
request.PageSize);
|
||||
|
||||
List<TelevisionShowCardViewModel> items = await _televisionRepository
|
||||
.GetShowsForCards(searchResult.Items.Map(i => i.Id).ToList())
|
||||
.Map(list => list.Map(ProjectToViewModel).ToList());
|
||||
|
||||
return new TelevisionShowCardResultsViewModel(searchResult.TotalCount, items, searchResult.PageMap);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace ErsatzTV.Application.Search
|
||||
{
|
||||
public class SearchResultViewModel<T>
|
||||
{
|
||||
public int TotalCount { get; set; }
|
||||
public List<T> Items { get; set; }
|
||||
}
|
||||
}
|
||||
+117
-28
@@ -6,17 +6,22 @@ using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Core.FFmpeg;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using LanguageExt;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using static LanguageExt.Prelude;
|
||||
|
||||
namespace ErsatzTV.Application.Streaming.Queries
|
||||
{
|
||||
public class
|
||||
GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<GetPlayoutItemProcessByChannelNumber>
|
||||
{
|
||||
private readonly IConfigElementRepository _configElementRepository;
|
||||
private readonly FFmpegProcessService _ffmpegProcessService;
|
||||
private readonly ILocalFileSystem _localFileSystem;
|
||||
private readonly ILogger<GetPlayoutItemProcessByChannelNumberHandler> _logger;
|
||||
private readonly IMediaSourceRepository _mediaSourceRepository;
|
||||
private readonly IPlayoutRepository _playoutRepository;
|
||||
@@ -27,12 +32,15 @@ namespace ErsatzTV.Application.Streaming.Queries
|
||||
IPlayoutRepository playoutRepository,
|
||||
IMediaSourceRepository mediaSourceRepository,
|
||||
FFmpegProcessService ffmpegProcessService,
|
||||
ILocalFileSystem localFileSystem,
|
||||
ILogger<GetPlayoutItemProcessByChannelNumberHandler> logger)
|
||||
: base(channelRepository, configElementRepository)
|
||||
{
|
||||
_configElementRepository = configElementRepository;
|
||||
_playoutRepository = playoutRepository;
|
||||
_mediaSourceRepository = mediaSourceRepository;
|
||||
_ffmpegProcessService = ffmpegProcessService;
|
||||
_localFileSystem = localFileSystem;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
@@ -42,49 +50,128 @@ namespace ErsatzTV.Application.Streaming.Queries
|
||||
string ffmpegPath)
|
||||
{
|
||||
DateTimeOffset now = DateTimeOffset.Now;
|
||||
Option<PlayoutItem> maybePlayoutItem = await _playoutRepository.GetPlayoutItem(channel.Id, now);
|
||||
return await maybePlayoutItem.Match<Task<Either<BaseError, Process>>>(
|
||||
async playoutItem =>
|
||||
Either<BaseError, PlayoutItemWithPath> maybePlayoutItem = await _playoutRepository
|
||||
.GetPlayoutItem(channel.Id, now)
|
||||
.Map(o => o.ToEither<BaseError>(new UnableToLocatePlayoutItem()))
|
||||
.BindT(ValidatePlayoutItemPath);
|
||||
|
||||
return await maybePlayoutItem.Match(
|
||||
async playoutItemWithPath =>
|
||||
{
|
||||
MediaVersion version = playoutItem.MediaItem switch
|
||||
MediaVersion version = playoutItemWithPath.PlayoutItem.MediaItem switch
|
||||
{
|
||||
Movie m => m.MediaVersions.Head(),
|
||||
Episode e => e.MediaVersions.Head(),
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(playoutItem))
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(playoutItemWithPath))
|
||||
};
|
||||
|
||||
MediaFile file = version.MediaFiles.Head();
|
||||
string path = file.Path;
|
||||
if (playoutItem.MediaItem is PlexMovie plexMovie)
|
||||
{
|
||||
path = await GetReplacementPlexPath(plexMovie.LibraryPathId, path);
|
||||
}
|
||||
bool saveReports = await _configElementRepository.GetValue<bool>(ConfigElementKey.FFmpegSaveReports)
|
||||
.Map(result => result.IfNone(false));
|
||||
|
||||
return _ffmpegProcessService.ForPlayoutItem(
|
||||
ffmpegPath,
|
||||
channel,
|
||||
version,
|
||||
path,
|
||||
playoutItem.StartOffset,
|
||||
now);
|
||||
return Right<BaseError, Process>(
|
||||
_ffmpegProcessService.ForPlayoutItem(
|
||||
ffmpegPath,
|
||||
saveReports,
|
||||
channel,
|
||||
version,
|
||||
playoutItemWithPath.Path,
|
||||
playoutItemWithPath.PlayoutItem.StartOffset,
|
||||
now));
|
||||
},
|
||||
async () =>
|
||||
async error =>
|
||||
{
|
||||
if (channel.FFmpegProfile.Transcode)
|
||||
var offlineTranscodeMessage =
|
||||
$"offline image is unavailable because transcoding is disabled in ffmpeg profile '{channel.FFmpegProfile.Name}'";
|
||||
|
||||
Option<TimeSpan> maybeDuration = await Optional(channel.FFmpegProfile.Transcode)
|
||||
.Filter(transcode => transcode)
|
||||
.Match(
|
||||
_ => _playoutRepository.GetNextItemStart(channel.Id, now)
|
||||
.MapT(nextStart => nextStart - now),
|
||||
() => Option<TimeSpan>.None.AsTask());
|
||||
|
||||
switch (error)
|
||||
{
|
||||
Option<TimeSpan> maybeDuration = await _playoutRepository.GetNextItemStart(channel.Id, now)
|
||||
.MapT(nextStart => nextStart - now);
|
||||
case UnableToLocatePlayoutItem:
|
||||
if (channel.FFmpegProfile.Transcode)
|
||||
{
|
||||
return _ffmpegProcessService.ForError(
|
||||
ffmpegPath,
|
||||
channel,
|
||||
maybeDuration,
|
||||
"Channel is Offline");
|
||||
}
|
||||
else
|
||||
{
|
||||
var message =
|
||||
$"Unable to locate playout item for channel {channel.Number}; {offlineTranscodeMessage}";
|
||||
|
||||
return _ffmpegProcessService.ForOfflineImage(ffmpegPath, channel, maybeDuration);
|
||||
return BaseError.New(message);
|
||||
}
|
||||
case PlayoutItemDoesNotExistOnDisk:
|
||||
if (channel.FFmpegProfile.Transcode)
|
||||
{
|
||||
return _ffmpegProcessService.ForError(ffmpegPath, channel, maybeDuration, error.Value);
|
||||
}
|
||||
else
|
||||
{
|
||||
var message =
|
||||
$"Playout item does not exist on disk for channel {channel.Number}; {offlineTranscodeMessage}";
|
||||
|
||||
return BaseError.New(message);
|
||||
}
|
||||
default:
|
||||
if (channel.FFmpegProfile.Transcode)
|
||||
{
|
||||
return _ffmpegProcessService.ForError(
|
||||
ffmpegPath,
|
||||
channel,
|
||||
maybeDuration,
|
||||
"Channel is Offline");
|
||||
}
|
||||
else
|
||||
{
|
||||
var message =
|
||||
$"Unexpected error locating playout item for channel {channel.Number}; {offlineTranscodeMessage}";
|
||||
|
||||
return BaseError.New(message);
|
||||
}
|
||||
}
|
||||
|
||||
var message =
|
||||
$"Unable to locate playout item for channel {channel.Number}; offline image is unavailable because transcoding is disabled in ffmpeg profile '{channel.FFmpegProfile.Name}'";
|
||||
|
||||
return BaseError.New(message);
|
||||
});
|
||||
}
|
||||
|
||||
private async Task<Either<BaseError, PlayoutItemWithPath>> ValidatePlayoutItemPath(PlayoutItem playoutItem)
|
||||
{
|
||||
string path = await GetPlayoutItemPath(playoutItem);
|
||||
|
||||
// TODO: this won't work with url streaming from plex
|
||||
if (_localFileSystem.FileExists(path))
|
||||
{
|
||||
return new PlayoutItemWithPath(playoutItem, path);
|
||||
}
|
||||
|
||||
return new PlayoutItemDoesNotExistOnDisk(path);
|
||||
}
|
||||
|
||||
private async Task<string> GetPlayoutItemPath(PlayoutItem playoutItem)
|
||||
{
|
||||
MediaVersion version = playoutItem.MediaItem switch
|
||||
{
|
||||
Movie m => m.MediaVersions.Head(),
|
||||
Episode e => e.MediaVersions.Head(),
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(playoutItem))
|
||||
};
|
||||
|
||||
MediaFile file = version.MediaFiles.Head();
|
||||
string path = file.Path;
|
||||
return playoutItem.MediaItem switch
|
||||
{
|
||||
PlexMovie plexMovie => await GetReplacementPlexPath(plexMovie.LibraryPathId, path),
|
||||
PlexEpisode plexEpisode => await GetReplacementPlexPath(plexEpisode.LibraryPathId, path),
|
||||
_ => path
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<string> GetReplacementPlexPath(int libraryPathId, string path)
|
||||
{
|
||||
List<PlexPathReplacement> replacements =
|
||||
@@ -105,5 +192,7 @@ namespace ErsatzTV.Application.Streaming.Queries
|
||||
},
|
||||
() => path);
|
||||
}
|
||||
|
||||
private record PlayoutItemWithPath(PlayoutItem PlayoutItem, string Path);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,8 @@ namespace ErsatzTV.Application.Television
|
||||
show.ShowMetadata.HeadOrNone().Map(GetPoster).IfNone(string.Empty),
|
||||
show.ShowMetadata.HeadOrNone().Map(GetFanArt).IfNone(string.Empty),
|
||||
show.ShowMetadata.HeadOrNone().Map(m => m.Genres.Map(g => g.Name).ToList()).IfNone(new List<string>()),
|
||||
show.ShowMetadata.HeadOrNone().Map(m => m.Tags.Map(g => g.Name).ToList()).IfNone(new List<string>()));
|
||||
show.ShowMetadata.HeadOrNone().Map(m => m.Tags.Map(g => g.Name).ToList()).IfNone(new List<string>()),
|
||||
show.ShowMetadata.HeadOrNone().Map(m => m.Studios.Map(s => s.Name).ToList()).IfNone(new List<string>()));
|
||||
|
||||
internal static TelevisionSeasonViewModel ProjectToViewModel(Season season) =>
|
||||
new(
|
||||
|
||||
@@ -10,5 +10,6 @@ namespace ErsatzTV.Application.Television
|
||||
string Poster,
|
||||
string FanArt,
|
||||
List<string> Genres,
|
||||
List<string> Tags);
|
||||
List<string> Tags,
|
||||
List<string> Studios);
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
<PackageReference Include="Microsoft.Extensions.Logging" Version="5.0.0" />
|
||||
<PackageReference Include="RestSharp" Version="106.11.7" />
|
||||
<PackageReference Include="Serilog" Version="2.10.0" />
|
||||
<PackageReference Include="Serilog.Extensions.Hosting" Version="3.1.0" />
|
||||
<PackageReference Include="Serilog.Extensions.Hosting" Version="4.1.2" />
|
||||
<PackageReference Include="Serilog.Extensions.Logging" Version="3.0.1" />
|
||||
<PackageReference Include="Serilog.Sinks.Console" Version="3.1.1" />
|
||||
</ItemGroup>
|
||||
|
||||
@@ -8,13 +8,13 @@
|
||||
<PackageReference Include="FluentAssertions" Version="5.10.3" />
|
||||
<PackageReference Include="LanguageExt.Core" Version="3.4.15" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="5.0.1" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.8.3" />
|
||||
<PackageReference Include="Moq" Version="4.16.0" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.9.1" />
|
||||
<PackageReference Include="Moq" Version="4.16.1" />
|
||||
<PackageReference Include="NUnit" Version="3.13.1" />
|
||||
<PackageReference Include="NUnit3TestAdapter" Version="3.17.0" />
|
||||
<PackageReference Include="Serilog" Version="2.10.0" />
|
||||
<PackageReference Include="Serilog.Extensions.Logging" Version="3.0.1" />
|
||||
<PackageReference Include="Serilog.Sinks.Debug" Version="1.0.1" />
|
||||
<PackageReference Include="Serilog.Sinks.Debug" Version="2.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -38,7 +38,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
{
|
||||
filter.ComplexFilter.Should().Be($"[0:a]apad=whole_dur={duration.TotalMilliseconds}ms[a]");
|
||||
filter.AudioLabel.Should().Be("[a]");
|
||||
filter.VideoLabel.Should().Be("0:v");
|
||||
filter.VideoLabel.Should().Be("0:V");
|
||||
});
|
||||
}
|
||||
|
||||
@@ -57,29 +57,29 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
filter =>
|
||||
{
|
||||
filter.ComplexFilter.Should().Be(
|
||||
$"[0:a]apad=whole_dur={duration.TotalMilliseconds}ms[a];[0:v]yadif=1[v]");
|
||||
$"[0:a]apad=whole_dur={duration.TotalMilliseconds}ms[a];[0:V]yadif=1[v]");
|
||||
filter.AudioLabel.Should().Be("[a]");
|
||||
filter.VideoLabel.Should().Be("[v]");
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
[TestCase(true, false, false, "[0:v]yadif=1[v]", "[v]")]
|
||||
[TestCase(true, true, false, "[0:v]yadif=1,scale=1920:1000:flags=fast_bilinear,setsar=1[v]", "[v]")]
|
||||
[TestCase(true, false, true, "[0:v]yadif=1,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2[v]", "[v]")]
|
||||
[TestCase(true, false, false, "[0:V]yadif=1[v]", "[v]")]
|
||||
[TestCase(true, true, false, "[0:V]yadif=1,scale=1920:1000:flags=fast_bilinear,setsar=1[v]", "[v]")]
|
||||
[TestCase(true, false, true, "[0:V]yadif=1,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2[v]", "[v]")]
|
||||
[TestCase(
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
"[0:v]yadif=1,scale=1920:1000:flags=fast_bilinear,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2[v]",
|
||||
"[0:V]yadif=1,scale=1920:1000:flags=fast_bilinear,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2[v]",
|
||||
"[v]")]
|
||||
[TestCase(false, true, false, "[0:v]scale=1920:1000:flags=fast_bilinear,setsar=1[v]", "[v]")]
|
||||
[TestCase(false, false, true, "[0:v]setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2[v]", "[v]")]
|
||||
[TestCase(false, true, false, "[0:V]scale=1920:1000:flags=fast_bilinear,setsar=1[v]", "[v]")]
|
||||
[TestCase(false, false, true, "[0:V]setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2[v]", "[v]")]
|
||||
[TestCase(
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
"[0:v]scale=1920:1000:flags=fast_bilinear,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2[v]",
|
||||
"[0:V]scale=1920:1000:flags=fast_bilinear,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2[v]",
|
||||
"[v]")]
|
||||
public void Should_Return_Software_Video_Filter(
|
||||
bool deinterlace,
|
||||
@@ -114,42 +114,42 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
}
|
||||
|
||||
[Test]
|
||||
[TestCase(true, false, false, "[0:v]deinterlace_qsv[v]", "[v]")]
|
||||
[TestCase(true, false, false, "[0:V]deinterlace_qsv[v]", "[v]")]
|
||||
[TestCase(
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
"[0:v]deinterlace_qsv,scale_qsv=w=1920:h=1000,hwdownload,format=nv12,setsar=1,hwupload=extra_hw_frames=64[v]",
|
||||
"[0:V]deinterlace_qsv,scale_qsv=w=1920:h=1000,hwdownload,format=nv12,setsar=1,hwupload=extra_hw_frames=64[v]",
|
||||
"[v]")]
|
||||
[TestCase(
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
"[0:v]deinterlace_qsv,hwdownload,format=nv12,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload=extra_hw_frames=64[v]",
|
||||
"[0:V]deinterlace_qsv,hwdownload,format=nv12,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload=extra_hw_frames=64[v]",
|
||||
"[v]")]
|
||||
[TestCase(
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
"[0:v]deinterlace_qsv,scale_qsv=w=1920:h=1000,hwdownload,format=nv12,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload=extra_hw_frames=64[v]",
|
||||
"[0:V]deinterlace_qsv,scale_qsv=w=1920:h=1000,hwdownload,format=nv12,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload=extra_hw_frames=64[v]",
|
||||
"[v]")]
|
||||
[TestCase(
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
"[0:v]scale_qsv=w=1920:h=1000,hwdownload,format=nv12,setsar=1,hwupload=extra_hw_frames=64[v]",
|
||||
"[0:V]scale_qsv=w=1920:h=1000,hwdownload,format=nv12,setsar=1,hwupload=extra_hw_frames=64[v]",
|
||||
"[v]")]
|
||||
[TestCase(
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
"[0:v]hwdownload,format=nv12,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload=extra_hw_frames=64[v]",
|
||||
"[0:V]hwdownload,format=nv12,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload=extra_hw_frames=64[v]",
|
||||
"[v]")]
|
||||
[TestCase(
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
"[0:v]scale_qsv=w=1920:h=1000,hwdownload,format=nv12,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload=extra_hw_frames=64[v]",
|
||||
"[0:V]scale_qsv=w=1920:h=1000,hwdownload,format=nv12,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload=extra_hw_frames=64[v]",
|
||||
"[v]")]
|
||||
public void Should_Return_QSV_Video_Filter(
|
||||
bool deinterlace,
|
||||
@@ -186,60 +186,60 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
|
||||
[Test]
|
||||
// TODO: get yadif_cuda working in docker
|
||||
// [TestCase(true, false, false, "[0:v]yadif_cuda[v]", "[v]")]
|
||||
// [TestCase(true, false, false, "[0:V]yadif_cuda[v]", "[v]")]
|
||||
// [TestCase(
|
||||
// true,
|
||||
// true,
|
||||
// false,
|
||||
// "[0:v]yadif_cuda,scale_npp=1920:1000:format=yuv420p,hwdownload,setsar=1,hwupload[v]",
|
||||
// "[0:V]yadif_cuda,scale_npp=1920:1000:format=yuv420p,hwdownload,setsar=1,hwupload[v]",
|
||||
// "[v]")]
|
||||
// [TestCase(
|
||||
// true,
|
||||
// false,
|
||||
// true,
|
||||
// "[0:v]yadif_cuda,hwdownload,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]",
|
||||
// "[0:V]yadif_cuda,hwdownload,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]",
|
||||
// "[v]")]
|
||||
// [TestCase(
|
||||
// true,
|
||||
// true,
|
||||
// true,
|
||||
// "[0:v]yadif_cuda,scale_npp=1920:1000:format=yuv420p,hwdownload,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]",
|
||||
// "[0:V]yadif_cuda,scale_npp=1920:1000:format=yuv420p,hwdownload,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]",
|
||||
// "[v]")]
|
||||
[TestCase(
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
"[0:v]scale_npp=1920:1000,hwdownload,format=nv12,setsar=1,hwupload[v]",
|
||||
"[0:V]scale_npp=1920:1000,hwdownload,format=nv12,setsar=1,hwupload[v]",
|
||||
"[v]")]
|
||||
[TestCase(
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
"[0:v]hwdownload,format=nv12,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]",
|
||||
"[0:V]hwdownload,format=nv12,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]",
|
||||
"[v]")]
|
||||
[TestCase(
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
"[0:v]scale_npp=1920:1000,hwdownload,format=nv12,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]",
|
||||
"[0:V]scale_npp=1920:1000,hwdownload,format=nv12,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]",
|
||||
"[v]")]
|
||||
[TestCase(
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
"[0:v]scale_npp=1920:1000,hwdownload,format=nv12,setsar=1,hwupload[v]",
|
||||
"[0:V]scale_npp=1920:1000,hwdownload,format=nv12,setsar=1,hwupload[v]",
|
||||
"[v]")]
|
||||
[TestCase(
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
"[0:v]hwdownload,format=nv12,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]",
|
||||
"[0:V]hwdownload,format=nv12,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]",
|
||||
"[v]")]
|
||||
[TestCase(
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
"[0:v]scale_npp=1920:1000,hwdownload,format=nv12,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]",
|
||||
"[0:V]scale_npp=1920:1000,hwdownload,format=nv12,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]",
|
||||
"[v]")]
|
||||
public void Should_Return_NVENC_Video_Filter(
|
||||
bool deinterlace,
|
||||
@@ -275,91 +275,91 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
}
|
||||
|
||||
[Test]
|
||||
[TestCase("h264", true, false, false, "[0:v]deinterlace_vaapi[v]", "[v]")]
|
||||
[TestCase("h264", true, false, false, "[0:V]deinterlace_vaapi[v]", "[v]")]
|
||||
[TestCase(
|
||||
"h264",
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
"[0:v]deinterlace_vaapi,scale_vaapi=w=1920:h=1000,hwdownload,format=nv12|vaapi,setsar=1,hwupload[v]",
|
||||
"[0:V]deinterlace_vaapi,scale_vaapi=w=1920:h=1000,hwdownload,format=nv12|vaapi,setsar=1,hwupload[v]",
|
||||
"[v]")]
|
||||
[TestCase(
|
||||
"h264",
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
"[0:v]deinterlace_vaapi,hwdownload,format=nv12|vaapi,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]",
|
||||
"[0:V]deinterlace_vaapi,hwdownload,format=nv12|vaapi,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]",
|
||||
"[v]")]
|
||||
[TestCase(
|
||||
"h264",
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
"[0:v]deinterlace_vaapi,scale_vaapi=w=1920:h=1000,hwdownload,format=nv12|vaapi,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]",
|
||||
"[0:V]deinterlace_vaapi,scale_vaapi=w=1920:h=1000,hwdownload,format=nv12|vaapi,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]",
|
||||
"[v]")]
|
||||
[TestCase(
|
||||
"h264",
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
"[0:v]scale_vaapi=w=1920:h=1000,hwdownload,format=nv12|vaapi,setsar=1,hwupload[v]",
|
||||
"[0:V]scale_vaapi=w=1920:h=1000,hwdownload,format=nv12|vaapi,setsar=1,hwupload[v]",
|
||||
"[v]")]
|
||||
[TestCase(
|
||||
"h264",
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
"[0:v]hwdownload,format=nv12|vaapi,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]",
|
||||
"[0:V]hwdownload,format=nv12|vaapi,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]",
|
||||
"[v]")]
|
||||
[TestCase(
|
||||
"h264",
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
"[0:v]scale_vaapi=w=1920:h=1000,hwdownload,format=nv12|vaapi,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]",
|
||||
"[0:V]scale_vaapi=w=1920:h=1000,hwdownload,format=nv12|vaapi,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]",
|
||||
"[v]")]
|
||||
[TestCase("mpeg4", true, false, false, "[0:v]hwupload,deinterlace_vaapi[v]", "[v]")]
|
||||
[TestCase("mpeg4", true, false, false, "[0:V]hwupload,deinterlace_vaapi[v]", "[v]")]
|
||||
[TestCase(
|
||||
"mpeg4",
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
"[0:v]hwupload,deinterlace_vaapi,scale_vaapi=w=1920:h=1000,hwdownload,format=nv12|vaapi,setsar=1,hwupload[v]",
|
||||
"[0:V]hwupload,deinterlace_vaapi,scale_vaapi=w=1920:h=1000,hwdownload,format=nv12|vaapi,setsar=1,hwupload[v]",
|
||||
"[v]")]
|
||||
[TestCase(
|
||||
"mpeg4",
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
"[0:v]hwupload,deinterlace_vaapi,hwdownload,format=nv12|vaapi,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]",
|
||||
"[0:V]hwupload,deinterlace_vaapi,hwdownload,format=nv12|vaapi,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]",
|
||||
"[v]")]
|
||||
[TestCase(
|
||||
"mpeg4",
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
"[0:v]hwupload,deinterlace_vaapi,scale_vaapi=w=1920:h=1000,hwdownload,format=nv12|vaapi,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]",
|
||||
"[0:V]hwupload,deinterlace_vaapi,scale_vaapi=w=1920:h=1000,hwdownload,format=nv12|vaapi,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]",
|
||||
"[v]")]
|
||||
[TestCase(
|
||||
"mpeg4",
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
"[0:v]hwupload,scale_vaapi=w=1920:h=1000,hwdownload,format=nv12|vaapi,setsar=1,hwupload[v]",
|
||||
"[0:V]hwupload,scale_vaapi=w=1920:h=1000,hwdownload,format=nv12|vaapi,setsar=1,hwupload[v]",
|
||||
"[v]")]
|
||||
[TestCase(
|
||||
"mpeg4",
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
"[0:v]setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]",
|
||||
"[0:V]setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]",
|
||||
"[v]")]
|
||||
[TestCase(
|
||||
"mpeg4",
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
"[0:v]hwupload,scale_vaapi=w=1920:h=1000,hwdownload,format=nv12|vaapi,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]",
|
||||
"[0:V]hwupload,scale_vaapi=w=1920:h=1000,hwdownload,format=nv12|vaapi,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]",
|
||||
"[v]")]
|
||||
public void Should_Return_VAAPI_Video_Filter(
|
||||
string codec,
|
||||
|
||||
@@ -36,6 +36,8 @@ namespace ErsatzTV.Core.Tests.Fakes
|
||||
_folders = allFolders.Distinct().Map(f => new FakeFolderEntry(f)).ToList();
|
||||
}
|
||||
|
||||
public Unit EnsureFolderExists(string folder) => Unit.Default;
|
||||
|
||||
public DateTime GetLastWriteTime(string path) =>
|
||||
Optional(_files.SingleOrDefault(f => f.Path == path))
|
||||
.Map(f => f.LastWriteTime)
|
||||
|
||||
@@ -14,17 +14,22 @@ namespace ErsatzTV.Core.Tests.Fakes
|
||||
private readonly Map<int, List<MediaItem>> _data;
|
||||
|
||||
public FakeMediaCollectionRepository(Map<int, List<MediaItem>> data) => _data = data;
|
||||
|
||||
public Task<Collection> Add(Collection collection) => throw new NotSupportedException();
|
||||
public Task<bool> AddMediaItem(int collectionId, int mediaItemId) => throw new NotSupportedException();
|
||||
public Task<bool> AddMediaItems(int collectionId, List<int> mediaItemIds) => throw new NotSupportedException();
|
||||
|
||||
public Task<Option<Collection>> Get(int id) => throw new NotSupportedException();
|
||||
public Task<Option<Collection>> GetCollectionWithItems(int id) => throw new NotSupportedException();
|
||||
public Task<Option<Collection>> GetCollectionWithItemsUntracked(int id) => throw new NotSupportedException();
|
||||
|
||||
public Task<Option<Collection>> GetCollectionWithCollectionItemsUntracked(int id) =>
|
||||
throw new NotSupportedException();
|
||||
|
||||
public Task<List<Collection>> GetAll() => 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();
|
||||
public Task<List<int>> PlayoutIdsUsingCollection(int collectionId) => throw new NotSupportedException();
|
||||
public Task<bool> IsCustomPlaybackOrder(int collectionId) => false.AsTask();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,26 +1,26 @@
|
||||
using System.Collections.Generic;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Metadata;
|
||||
|
||||
namespace ErsatzTV.Core.Tests.Fakes
|
||||
{
|
||||
public class FakeMovieWithPath : Movie
|
||||
public class FakeMovieWithPath : MediaItemScanResult<Movie>
|
||||
{
|
||||
public FakeMovieWithPath(string path)
|
||||
{
|
||||
Path = path;
|
||||
|
||||
MediaVersions = new List<MediaVersion>
|
||||
{
|
||||
new()
|
||||
: base(
|
||||
new Movie
|
||||
{
|
||||
MediaFiles = new List<MediaFile>
|
||||
MediaVersions = new List<MediaVersion>
|
||||
{
|
||||
new() { Path = path }
|
||||
new()
|
||||
{
|
||||
MediaFiles = new List<MediaFile>
|
||||
{
|
||||
new() { Path = path }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
public string Path { get; }
|
||||
}) =>
|
||||
IsAdded = true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Metadata;
|
||||
using LanguageExt;
|
||||
|
||||
namespace ErsatzTV.Core.Tests.Fakes
|
||||
@@ -11,12 +12,6 @@ namespace ErsatzTV.Core.Tests.Fakes
|
||||
{
|
||||
public Task<bool> AllShowsExist(List<int> showIds) => throw new NotSupportedException();
|
||||
|
||||
public Task<bool> Update(Show show) => throw new NotSupportedException();
|
||||
|
||||
public Task<bool> Update(Season season) => throw new NotSupportedException();
|
||||
|
||||
public Task<bool> Update(Episode episode) => throw new NotSupportedException();
|
||||
|
||||
public Task<List<Show>> GetAllShows() => throw new NotSupportedException();
|
||||
|
||||
public Task<Option<Show>> GetShow(int showId) => throw new NotSupportedException();
|
||||
@@ -26,6 +21,8 @@ namespace ErsatzTV.Core.Tests.Fakes
|
||||
public Task<List<ShowMetadata>> GetPagedShows(int pageNumber, int pageSize) =>
|
||||
throw new NotSupportedException();
|
||||
|
||||
public Task<List<ShowMetadata>> GetShowsForCards(List<int> ids) => throw new NotSupportedException();
|
||||
|
||||
public Task<List<Episode>> GetShowItems(int showId) => throw new NotSupportedException();
|
||||
|
||||
public Task<List<Season>> GetAllSeasons() => throw new NotSupportedException();
|
||||
@@ -49,7 +46,7 @@ namespace ErsatzTV.Core.Tests.Fakes
|
||||
public Task<Option<Show>> GetShowByMetadata(int libraryPathId, ShowMetadata metadata) =>
|
||||
throw new NotSupportedException();
|
||||
|
||||
public Task<Either<BaseError, Show>>
|
||||
public Task<Either<BaseError, MediaItemScanResult<Show>>>
|
||||
AddShow(int libraryPathId, string showFolder, ShowMetadata metadata) =>
|
||||
throw new NotSupportedException();
|
||||
|
||||
@@ -65,6 +62,39 @@ namespace ErsatzTV.Core.Tests.Fakes
|
||||
|
||||
public Task<Unit> DeleteEmptySeasons(LibraryPath libraryPath) => throw new NotSupportedException();
|
||||
|
||||
public Task<Unit> DeleteEmptyShows(LibraryPath libraryPath) => throw new NotSupportedException();
|
||||
public Task<List<int>> DeleteEmptyShows(LibraryPath libraryPath) => throw new NotSupportedException();
|
||||
|
||||
public Task<Either<BaseError, MediaItemScanResult<PlexShow>>> GetOrAddPlexShow(
|
||||
PlexLibrary library,
|
||||
PlexShow item) =>
|
||||
throw new NotSupportedException();
|
||||
|
||||
public Task<Either<BaseError, PlexSeason>> GetOrAddPlexSeason(PlexLibrary library, PlexSeason item) =>
|
||||
throw new NotSupportedException();
|
||||
|
||||
public Task<Either<BaseError, PlexEpisode>> GetOrAddPlexEpisode(PlexLibrary library, PlexEpisode item) =>
|
||||
throw new NotSupportedException();
|
||||
|
||||
public Task<bool> AddGenre(ShowMetadata metadata, Genre genre) => throw new NotSupportedException();
|
||||
public Task<bool> AddTag(ShowMetadata metadata, Tag tag) => throw new NotSupportedException();
|
||||
|
||||
public Task<bool> AddStudio(ShowMetadata metadata, Studio studio) => throw new NotSupportedException();
|
||||
|
||||
public Task<List<int>> RemoveMissingPlexShows(PlexLibrary library, List<string> showKeys) =>
|
||||
throw new NotSupportedException();
|
||||
|
||||
public Task<Unit> RemoveMissingPlexSeasons(string showKey, List<string> seasonKeys) =>
|
||||
throw new NotSupportedException();
|
||||
|
||||
public Task<Unit> RemoveMissingPlexEpisodes(string seasonKey, List<string> episodeKeys) =>
|
||||
throw new NotSupportedException();
|
||||
|
||||
public Task<Unit> SetEpisodeNumber(Episode episode, int episodeNumber) => throw new NotSupportedException();
|
||||
|
||||
public Task<bool> Update(Show show) => throw new NotSupportedException();
|
||||
|
||||
public Task<bool> Update(Season season) => throw new NotSupportedException();
|
||||
|
||||
public Task<bool> Update(Episode episode) => throw new NotSupportedException();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Core.Interfaces.Images;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using ErsatzTV.Core.Metadata;
|
||||
using ErsatzTV.Core.Tests.Fakes;
|
||||
using FluentAssertions;
|
||||
@@ -44,20 +45,24 @@ namespace ErsatzTV.Core.Tests.Metadata
|
||||
_movieRepository = new Mock<IMovieRepository>();
|
||||
_movieRepository.Setup(x => x.GetOrAdd(It.IsAny<LibraryPath>(), It.IsAny<string>()))
|
||||
.Returns(
|
||||
(LibraryPath _, string path) => Right<BaseError, Movie>(new FakeMovieWithPath(path)).AsTask());
|
||||
(LibraryPath _, string path) =>
|
||||
Right<BaseError, MediaItemScanResult<Movie>>(new FakeMovieWithPath(path)).AsTask());
|
||||
_movieRepository.Setup(x => x.FindMoviePaths(It.IsAny<LibraryPath>()))
|
||||
.Returns(new List<string>().AsEnumerable().AsTask());
|
||||
|
||||
_localStatisticsProvider = new Mock<ILocalStatisticsProvider>();
|
||||
_localMetadataProvider = new Mock<ILocalMetadataProvider>();
|
||||
|
||||
_localStatisticsProvider.Setup(x => x.RefreshStatistics(It.IsAny<string>(), It.IsAny<MediaItem>()))
|
||||
.Returns<string, MediaItem>((_, _) => Right<BaseError, bool>(true).AsTask());
|
||||
|
||||
// fallback metadata adds metadata to a movie, so we need to replicate that here
|
||||
_localMetadataProvider.Setup(x => x.RefreshFallbackMetadata(It.IsAny<MediaItem>()))
|
||||
.Returns(
|
||||
(MediaItem mediaItem) =>
|
||||
{
|
||||
((Movie) mediaItem).MovieMetadata = new List<MovieMetadata> { new() };
|
||||
return Unit.Default.AsTask();
|
||||
return Task.FromResult(true);
|
||||
});
|
||||
|
||||
_imageCache = new Mock<IImageCache>();
|
||||
@@ -104,11 +109,14 @@ namespace ErsatzTV.Core.Tests.Metadata
|
||||
_movieRepository.Verify(x => x.GetOrAdd(libraryPath, moviePath), Times.Once);
|
||||
|
||||
_localStatisticsProvider.Verify(
|
||||
x => x.RefreshStatistics(FFprobePath, It.Is<FakeMovieWithPath>(i => i.Path == moviePath)),
|
||||
x => x.RefreshStatistics(
|
||||
FFprobePath,
|
||||
It.Is<Movie>(i => i.MediaVersions.Head().MediaFiles.Head().Path == moviePath)),
|
||||
Times.Once);
|
||||
|
||||
_localMetadataProvider.Verify(
|
||||
x => x.RefreshFallbackMetadata(It.Is<FakeMovieWithPath>(i => i.Path == moviePath)),
|
||||
x => x.RefreshFallbackMetadata(
|
||||
It.Is<Movie>(i => i.MediaVersions.Head().MediaFiles.Head().Path == moviePath)),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
@@ -137,11 +145,15 @@ namespace ErsatzTV.Core.Tests.Metadata
|
||||
_movieRepository.Verify(x => x.GetOrAdd(libraryPath, moviePath), Times.Once);
|
||||
|
||||
_localStatisticsProvider.Verify(
|
||||
x => x.RefreshStatistics(FFprobePath, It.Is<FakeMovieWithPath>(i => i.Path == moviePath)),
|
||||
x => x.RefreshStatistics(
|
||||
FFprobePath,
|
||||
It.Is<Movie>(i => i.MediaVersions.Head().MediaFiles.Head().Path == moviePath)),
|
||||
Times.Once);
|
||||
|
||||
_localMetadataProvider.Verify(
|
||||
x => x.RefreshSidecarMetadata(It.Is<FakeMovieWithPath>(i => i.Path == moviePath), metadataPath),
|
||||
x => x.RefreshSidecarMetadata(
|
||||
It.Is<Movie>(i => i.MediaVersions.Head().MediaFiles.Head().Path == moviePath),
|
||||
metadataPath),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
@@ -170,11 +182,15 @@ namespace ErsatzTV.Core.Tests.Metadata
|
||||
_movieRepository.Verify(x => x.GetOrAdd(libraryPath, moviePath), Times.Once);
|
||||
|
||||
_localStatisticsProvider.Verify(
|
||||
x => x.RefreshStatistics(FFprobePath, It.Is<FakeMovieWithPath>(i => i.Path == moviePath)),
|
||||
x => x.RefreshStatistics(
|
||||
FFprobePath,
|
||||
It.Is<Movie>(i => i.MediaVersions.Head().MediaFiles.Head().Path == moviePath)),
|
||||
Times.Once);
|
||||
|
||||
_localMetadataProvider.Verify(
|
||||
x => x.RefreshSidecarMetadata(It.Is<FakeMovieWithPath>(i => i.Path == moviePath), metadataPath),
|
||||
x => x.RefreshSidecarMetadata(
|
||||
It.Is<Movie>(i => i.MediaVersions.Head().MediaFiles.Head().Path == moviePath),
|
||||
metadataPath),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
@@ -207,11 +223,14 @@ namespace ErsatzTV.Core.Tests.Metadata
|
||||
_movieRepository.Verify(x => x.GetOrAdd(libraryPath, moviePath), Times.Once);
|
||||
|
||||
_localStatisticsProvider.Verify(
|
||||
x => x.RefreshStatistics(FFprobePath, It.Is<FakeMovieWithPath>(i => i.Path == moviePath)),
|
||||
x => x.RefreshStatistics(
|
||||
FFprobePath,
|
||||
It.Is<Movie>(i => i.MediaVersions.Head().MediaFiles.Head().Path == moviePath)),
|
||||
Times.Once);
|
||||
|
||||
_localMetadataProvider.Verify(
|
||||
x => x.RefreshFallbackMetadata(It.Is<FakeMovieWithPath>(i => i.Path == moviePath)),
|
||||
x => x.RefreshFallbackMetadata(
|
||||
It.Is<Movie>(i => i.MediaVersions.Head().MediaFiles.Head().Path == moviePath)),
|
||||
Times.Once);
|
||||
|
||||
_imageCache.Verify(
|
||||
@@ -248,11 +267,14 @@ namespace ErsatzTV.Core.Tests.Metadata
|
||||
_movieRepository.Verify(x => x.GetOrAdd(libraryPath, moviePath), Times.Once);
|
||||
|
||||
_localStatisticsProvider.Verify(
|
||||
x => x.RefreshStatistics(FFprobePath, It.Is<FakeMovieWithPath>(i => i.Path == moviePath)),
|
||||
x => x.RefreshStatistics(
|
||||
FFprobePath,
|
||||
It.Is<Movie>(i => i.MediaVersions.Head().MediaFiles.Head().Path == moviePath)),
|
||||
Times.Once);
|
||||
|
||||
_localMetadataProvider.Verify(
|
||||
x => x.RefreshFallbackMetadata(It.Is<FakeMovieWithPath>(i => i.Path == moviePath)),
|
||||
x => x.RefreshFallbackMetadata(
|
||||
It.Is<Movie>(i => i.MediaVersions.Head().MediaFiles.Head().Path == moviePath)),
|
||||
Times.Once);
|
||||
|
||||
_imageCache.Verify(
|
||||
@@ -288,11 +310,14 @@ namespace ErsatzTV.Core.Tests.Metadata
|
||||
_movieRepository.Verify(x => x.GetOrAdd(libraryPath, moviePath), Times.Once);
|
||||
|
||||
_localStatisticsProvider.Verify(
|
||||
x => x.RefreshStatistics(FFprobePath, It.Is<FakeMovieWithPath>(i => i.Path == moviePath)),
|
||||
x => x.RefreshStatistics(
|
||||
FFprobePath,
|
||||
It.Is<Movie>(i => i.MediaVersions.Head().MediaFiles.Head().Path == moviePath)),
|
||||
Times.Once);
|
||||
|
||||
_localMetadataProvider.Verify(
|
||||
x => x.RefreshFallbackMetadata(It.Is<FakeMovieWithPath>(i => i.Path == moviePath)),
|
||||
x => x.RefreshFallbackMetadata(
|
||||
It.Is<Movie>(i => i.MediaVersions.Head().MediaFiles.Head().Path == moviePath)),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
@@ -324,11 +349,14 @@ namespace ErsatzTV.Core.Tests.Metadata
|
||||
_movieRepository.Verify(x => x.GetOrAdd(libraryPath, moviePath), Times.Once);
|
||||
|
||||
_localStatisticsProvider.Verify(
|
||||
x => x.RefreshStatistics(FFprobePath, It.Is<FakeMovieWithPath>(i => i.Path == moviePath)),
|
||||
x => x.RefreshStatistics(
|
||||
FFprobePath,
|
||||
It.Is<Movie>(i => i.MediaVersions.Head().MediaFiles.Head().Path == moviePath)),
|
||||
Times.Once);
|
||||
|
||||
_localMetadataProvider.Verify(
|
||||
x => x.RefreshFallbackMetadata(It.Is<FakeMovieWithPath>(i => i.Path == moviePath)),
|
||||
x => x.RefreshFallbackMetadata(
|
||||
It.Is<Movie>(i => i.MediaVersions.Head().MediaFiles.Head().Path == moviePath)),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
@@ -354,11 +382,14 @@ namespace ErsatzTV.Core.Tests.Metadata
|
||||
_movieRepository.Verify(x => x.GetOrAdd(libraryPath, moviePath), Times.Once);
|
||||
|
||||
_localStatisticsProvider.Verify(
|
||||
x => x.RefreshStatistics(FFprobePath, It.Is<FakeMovieWithPath>(i => i.Path == moviePath)),
|
||||
x => x.RefreshStatistics(
|
||||
FFprobePath,
|
||||
It.Is<Movie>(i => i.MediaVersions.Head().MediaFiles.Head().Path == moviePath)),
|
||||
Times.Once);
|
||||
|
||||
_localMetadataProvider.Verify(
|
||||
x => x.RefreshFallbackMetadata(It.Is<FakeMovieWithPath>(i => i.Path == moviePath)),
|
||||
x => x.RefreshFallbackMetadata(
|
||||
It.Is<Movie>(i => i.MediaVersions.Head().MediaFiles.Head().Path == moviePath)),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
@@ -417,7 +448,9 @@ namespace ErsatzTV.Core.Tests.Metadata
|
||||
_movieRepository.Object,
|
||||
_localStatisticsProvider.Object,
|
||||
_localMetadataProvider.Object,
|
||||
new Mock<IMetadataRepository>().Object,
|
||||
_imageCache.Object,
|
||||
new Mock<ISearchIndex>().Object,
|
||||
new Mock<ILogger<MovieFolderScanner>>().Object
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Scheduling;
|
||||
using FluentAssertions;
|
||||
using NUnit.Framework;
|
||||
using static LanguageExt.Prelude;
|
||||
|
||||
namespace ErsatzTV.Core.Tests.Scheduling
|
||||
{
|
||||
public class CustomOrderContentTests
|
||||
{
|
||||
[Test]
|
||||
public void MediaItems_Should_Sort_By_CustomOrder()
|
||||
{
|
||||
Collection collection = CreateCollection(10);
|
||||
List<MediaItem> contents = Episodes(10);
|
||||
var state = new CollectionEnumeratorState();
|
||||
|
||||
var customOrderContent = new CustomOrderCollectionEnumerator(collection, contents, state);
|
||||
|
||||
for (var i = 10; i >= 1; i--)
|
||||
{
|
||||
customOrderContent.Current.IsSome.Should().BeTrue();
|
||||
customOrderContent.Current.Map(x => x.Id).IfNone(-1).Should().Be(i);
|
||||
customOrderContent.MoveNext();
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void State_Index_Should_Increment()
|
||||
{
|
||||
Collection collection = CreateCollection(10);
|
||||
List<MediaItem> contents = Episodes(10);
|
||||
var state = new CollectionEnumeratorState();
|
||||
|
||||
var customOrderContent = new CustomOrderCollectionEnumerator(collection, contents, state);
|
||||
|
||||
for (var i = 0; i < 10; i++)
|
||||
{
|
||||
customOrderContent.State.Index.Should().Be(i % 10);
|
||||
customOrderContent.MoveNext();
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void State_Should_Impact_Iterator_Start()
|
||||
{
|
||||
Collection collection = CreateCollection(10);
|
||||
List<MediaItem> contents = Episodes(10);
|
||||
var state = new CollectionEnumeratorState { Index = 5 };
|
||||
|
||||
var customOrderContent = new CustomOrderCollectionEnumerator(collection, contents, state);
|
||||
|
||||
for (var i = 5; i >= 1; i--)
|
||||
{
|
||||
customOrderContent.Current.IsSome.Should().BeTrue();
|
||||
customOrderContent.Current.Map(x => x.Id).IfNone(-1).Should().Be(i);
|
||||
customOrderContent.State.Index.Should().Be(5 - i + 5); // 5 through 10
|
||||
customOrderContent.MoveNext();
|
||||
}
|
||||
}
|
||||
|
||||
private static Collection CreateCollection(int episodeCount)
|
||||
{
|
||||
var collection = new Collection { CollectionItems = new List<CollectionItem>() };
|
||||
|
||||
for (var i = 1; i <= episodeCount; i++)
|
||||
{
|
||||
collection.CollectionItems.Add(
|
||||
new CollectionItem
|
||||
{
|
||||
MediaItemId = i,
|
||||
// reverse order
|
||||
CustomIndex = episodeCount - i
|
||||
});
|
||||
}
|
||||
|
||||
return collection;
|
||||
}
|
||||
|
||||
|
||||
private static List<MediaItem> Episodes(int count) =>
|
||||
Range(1, count).Map(
|
||||
i => (MediaItem) new Episode
|
||||
{
|
||||
Id = i,
|
||||
EpisodeMetadata = new List<EpisodeMetadata>
|
||||
{
|
||||
new()
|
||||
{
|
||||
ReleaseDate = new DateTime(2020, 1, i)
|
||||
}
|
||||
}
|
||||
})
|
||||
.Reverse()
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
@@ -37,6 +37,24 @@ namespace ErsatzTV.Core.Tests.Scheduling
|
||||
_logger = factory.CreateLogger<PlayoutBuilder>();
|
||||
}
|
||||
|
||||
[Test]
|
||||
[Timeout(2000)]
|
||||
public async Task ZeroDurationItem_Should_Abort()
|
||||
{
|
||||
var mediaItems = new List<MediaItem>
|
||||
{
|
||||
TestMovie(1, TimeSpan.Zero, DateTime.Today)
|
||||
};
|
||||
|
||||
(PlayoutBuilder builder, Playout playout) = TestDataFloodForItems(mediaItems, PlaybackOrder.Random);
|
||||
DateTimeOffset start = HoursAfterMidnight(0);
|
||||
DateTimeOffset finish = start + TimeSpan.FromHours(6);
|
||||
|
||||
Playout result = await builder.BuildPlayoutItems(playout, start, finish);
|
||||
|
||||
result.Items.Should().BeNull();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task InitialFlood_Should_StartAtMidnight()
|
||||
{
|
||||
|
||||
@@ -15,6 +15,54 @@ namespace ErsatzTV.Core.Tests.Scheduling
|
||||
// this seed will produce (shuffle) 1-10 in order
|
||||
private const int MagicSeed = 670596;
|
||||
|
||||
[Test]
|
||||
public void Episodes_Should_Not_Duplicate_When_Reshuffling()
|
||||
{
|
||||
List<MediaItem> contents = Episodes(10);
|
||||
|
||||
// normally returns 10 5 7 4 3 6 2 8 9 1 1 (note duplicate 1 at end)
|
||||
var state = new CollectionEnumeratorState { Seed = 8 };
|
||||
|
||||
var shuffledContent = new ShuffledMediaCollectionEnumerator(contents, state);
|
||||
|
||||
var list = new List<int>();
|
||||
for (var i = 1; i <= 1000; i++)
|
||||
{
|
||||
shuffledContent.Current.IsSome.Should().BeTrue();
|
||||
shuffledContent.Current.Do(x => list.Add(x.Id));
|
||||
shuffledContent.MoveNext();
|
||||
}
|
||||
|
||||
for (var i = 0; i < list.Count - 1; i++)
|
||||
{
|
||||
if (list[i] == list[i + 1])
|
||||
{
|
||||
Assert.Fail("List contains duplicate items");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
[Timeout(2000)]
|
||||
public void Duplicate_Check_Should_Ignore_Single_Item()
|
||||
{
|
||||
List<MediaItem> contents = Episodes(1);
|
||||
|
||||
var state = new CollectionEnumeratorState();
|
||||
|
||||
var shuffledContent = new ShuffledMediaCollectionEnumerator(contents, state);
|
||||
|
||||
var list = new List<int>();
|
||||
for (var i = 1; i <= 10; i++)
|
||||
{
|
||||
shuffledContent.Current.IsSome.Should().BeTrue();
|
||||
shuffledContent.Current.Do(x => list.Add(x.Id));
|
||||
shuffledContent.MoveNext();
|
||||
}
|
||||
|
||||
list.Should().Equal(1, 1, 1, 1, 1, 1, 1, 1, 1, 1);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Episodes_Should_Shuffle()
|
||||
{
|
||||
|
||||
@@ -6,6 +6,7 @@ namespace ErsatzTV.Core.Domain
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string Name { get; set; }
|
||||
public bool UseCustomPlaybackOrder { get; set; }
|
||||
public List<MediaItem> MediaItems { get; set; }
|
||||
public List<CollectionItem> CollectionItems { get; set; }
|
||||
}
|
||||
|
||||
@@ -6,5 +6,6 @@
|
||||
public Collection Collection { get; set; }
|
||||
public int MediaItemId { get; set; }
|
||||
public MediaItem MediaItem { get; set; }
|
||||
public int? CustomIndex { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,5 +10,7 @@
|
||||
public static ConfigElementKey FFprobePath => new("ffmpeg.ffprobe_path");
|
||||
public static ConfigElementKey FFmpegDefaultProfileId => new("ffmpeg.default_profile_id");
|
||||
public static ConfigElementKey FFmpegDefaultResolutionId => new("ffmpeg.default_resolution_id");
|
||||
public static ConfigElementKey FFmpegSaveReports => new("ffmpeg.save_reports");
|
||||
public static ConfigElementKey SearchIndexVersion => new("search_index.version");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace ErsatzTV.Core.Domain
|
||||
{
|
||||
public class PlexEpisode : Episode
|
||||
{
|
||||
public string Key { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace ErsatzTV.Core.Domain
|
||||
{
|
||||
public class PlexSeason : Season
|
||||
{
|
||||
public string Key { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace ErsatzTV.Core.Domain
|
||||
{
|
||||
public class PlexShow : Show
|
||||
{
|
||||
public string Key { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -17,5 +17,6 @@ namespace ErsatzTV.Core.Domain
|
||||
public List<Artwork> Artwork { get; set; }
|
||||
public List<Genre> Genres { get; set; }
|
||||
public List<Tag> Tags { get; set; }
|
||||
public List<Studio> Studios { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace ErsatzTV.Core.Domain
|
||||
{
|
||||
public class Studio
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string Name { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace ErsatzTV.Core.Errors
|
||||
{
|
||||
public class PlayoutItemDoesNotExistOnDisk : BaseError
|
||||
{
|
||||
public PlayoutItemDoesNotExistOnDisk(string path) : base($"Playout item does not exist on disk\n{path}")
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace ErsatzTV.Core.Errors
|
||||
{
|
||||
public class UnableToLocatePlayoutItem : BaseError
|
||||
{
|
||||
public UnableToLocatePlayoutItem() : base("Unable to locate playout item")
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -58,7 +58,7 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
{
|
||||
var complexFilter = new StringBuilder();
|
||||
|
||||
var videoLabel = "0:v";
|
||||
var videoLabel = "0:V";
|
||||
var audioLabel = "0:a";
|
||||
|
||||
HardwareAccelerationKind acceleration = _hardwareAccelerationKind.IfNone(HardwareAccelerationKind.None);
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.FFmpeg;
|
||||
@@ -39,9 +40,14 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
|
||||
private readonly List<string> _arguments = new();
|
||||
private readonly string _ffmpegPath;
|
||||
private readonly bool _saveReports;
|
||||
private FFmpegComplexFilterBuilder _complexFilterBuilder = new();
|
||||
|
||||
public FFmpegProcessBuilder(string ffmpegPath) => _ffmpegPath = ffmpegPath;
|
||||
public FFmpegProcessBuilder(string ffmpegPath, bool saveReports)
|
||||
{
|
||||
_ffmpegPath = ffmpegPath;
|
||||
_saveReports = saveReports;
|
||||
}
|
||||
|
||||
public FFmpegProcessBuilder WithThreads(int threads)
|
||||
{
|
||||
@@ -218,13 +224,14 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
public FFmpegProcessBuilder WithErrorText(IDisplaySize desiredResolution, string text)
|
||||
{
|
||||
const string FONT_FILE = "fontfile=Resources/Roboto-Regular.ttf";
|
||||
const string FONT_SIZE = "fontsize=60";
|
||||
const string FONT_COLOR = "fontcolor=white";
|
||||
const string X = "x=(w-text_w)/2";
|
||||
const string Y = "y=(h-text_h)/3*2";
|
||||
|
||||
string fontSize = text.Length > 60 ? "fontsize=40" : "fontsize=60";
|
||||
|
||||
return WithFilterComplex(
|
||||
$"[0:0]scale={desiredResolution.Width}:{desiredResolution.Height},drawtext={FONT_FILE}:{FONT_SIZE}:{FONT_COLOR}:{X}:{Y}:text='{text}'[v]",
|
||||
$"[0:0]scale={desiredResolution.Width}:{desiredResolution.Height},drawtext={FONT_FILE}:{fontSize}:{FONT_COLOR}:{X}:{Y}:text='{text}'[v]",
|
||||
"[v]",
|
||||
"1:a");
|
||||
}
|
||||
@@ -324,7 +331,7 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
|
||||
public FFmpegProcessBuilder WithFilterComplex()
|
||||
{
|
||||
var videoLabel = "0:v";
|
||||
var videoLabel = "0:V";
|
||||
var audioLabel = "0:a";
|
||||
|
||||
Option<FFmpegComplexFilter> maybeFilter = _complexFilterBuilder.Build();
|
||||
@@ -364,6 +371,12 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
StandardOutputEncoding = Encoding.UTF8
|
||||
};
|
||||
|
||||
if (_saveReports)
|
||||
{
|
||||
string fileName = Path.Combine(FileSystemLayout.FFmpegReportsFolder, "%p-%t.log");
|
||||
startInfo.EnvironmentVariables.Add("FFREPORT", $"file={fileName}:level=32");
|
||||
}
|
||||
|
||||
foreach (string argument in _arguments)
|
||||
{
|
||||
startInfo.ArgumentList.Add(argument);
|
||||
|
||||
@@ -15,6 +15,7 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
|
||||
public Process ForPlayoutItem(
|
||||
string ffmpegPath,
|
||||
bool saveReports,
|
||||
Channel channel,
|
||||
MediaVersion version,
|
||||
string path,
|
||||
@@ -28,7 +29,7 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
start,
|
||||
now);
|
||||
|
||||
FFmpegProcessBuilder builder = new FFmpegProcessBuilder(ffmpegPath)
|
||||
FFmpegProcessBuilder builder = new FFmpegProcessBuilder(ffmpegPath, saveReports)
|
||||
.WithThreads(playbackSettings.ThreadCount)
|
||||
.WithHardwareAcceleration(playbackSettings.HardwareAcceleration)
|
||||
.WithQuiet()
|
||||
@@ -84,14 +85,14 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
.Build();
|
||||
}
|
||||
|
||||
public Process ForOfflineImage(string ffmpegPath, Channel channel, Option<TimeSpan> duration)
|
||||
public Process ForError(string ffmpegPath, Channel channel, Option<TimeSpan> duration, string errorMessage)
|
||||
{
|
||||
FFmpegPlaybackSettings playbackSettings =
|
||||
_playbackSettingsCalculator.CalculateErrorSettings(channel.FFmpegProfile);
|
||||
|
||||
IDisplaySize desiredResolution = channel.FFmpegProfile.Resolution;
|
||||
|
||||
FFmpegProcessBuilder builder = new FFmpegProcessBuilder(ffmpegPath)
|
||||
FFmpegProcessBuilder builder = new FFmpegProcessBuilder(ffmpegPath, false)
|
||||
.WithThreads(1)
|
||||
.WithQuiet()
|
||||
.WithFormatFlags(playbackSettings.FormatFlags)
|
||||
@@ -99,7 +100,7 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
.WithLoopedImage("Resources/background.png")
|
||||
.WithLibavfilter()
|
||||
.WithInput("anullsrc")
|
||||
.WithErrorText(desiredResolution, "Channel is Offline")
|
||||
.WithErrorText(desiredResolution, errorMessage)
|
||||
.WithPixfmt("yuv420p")
|
||||
.WithPlaybackArgs(playbackSettings)
|
||||
.WithMetadata(channel)
|
||||
@@ -114,7 +115,7 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
{
|
||||
FFmpegPlaybackSettings playbackSettings = _playbackSettingsCalculator.ConcatSettings;
|
||||
|
||||
return new FFmpegProcessBuilder(ffmpegPath)
|
||||
return new FFmpegProcessBuilder(ffmpegPath, false)
|
||||
.WithThreads(1)
|
||||
.WithQuiet()
|
||||
.WithFormatFlags(playbackSettings.FormatFlags)
|
||||
|
||||
@@ -19,6 +19,9 @@ namespace ErsatzTV.Core
|
||||
|
||||
public static readonly string PlexSecretsPath = Path.Combine(AppDataFolder, "plex-secrets.json");
|
||||
|
||||
public static readonly string FFmpegReportsFolder = Path.Combine(AppDataFolder, "ffmpeg-reports");
|
||||
public static readonly string SearchIndexFolder = Path.Combine(AppDataFolder, "search-index");
|
||||
|
||||
public static readonly string ArtworkCacheFolder = Path.Combine(AppDataFolder, "cache", "artwork");
|
||||
|
||||
public static readonly string PosterCacheFolder = Path.Combine(ArtworkCacheFolder, "posters");
|
||||
|
||||
@@ -5,8 +5,12 @@ namespace ErsatzTV.Core.Interfaces.Locking
|
||||
public interface IEntityLocker
|
||||
{
|
||||
event EventHandler OnLibraryChanged;
|
||||
event EventHandler OnPlexChanged;
|
||||
bool LockLibrary(int libraryId);
|
||||
bool UnlockLibrary(int libraryId);
|
||||
bool IsLibraryLocked(int libraryId);
|
||||
bool LockPlex();
|
||||
bool UnlockPlex();
|
||||
bool IsPlexLocked();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ namespace ErsatzTV.Core.Interfaces.Metadata
|
||||
{
|
||||
public interface ILocalFileSystem
|
||||
{
|
||||
Unit EnsureFolderExists(string folder);
|
||||
DateTime GetLastWriteTime(string path);
|
||||
bool IsLibraryPathAccessible(LibraryPath libraryPath);
|
||||
IEnumerable<string> ListSubdirectories(string folder);
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using LanguageExt;
|
||||
|
||||
namespace ErsatzTV.Core.Interfaces.Metadata
|
||||
{
|
||||
public interface ILocalMetadataProvider
|
||||
{
|
||||
Task<ShowMetadata> GetMetadataForShow(string showFolder);
|
||||
Task<Unit> RefreshSidecarMetadata(MediaItem mediaItem, string path);
|
||||
Task<Unit> RefreshSidecarMetadata(Show televisionShow, string showFolder);
|
||||
Task<Unit> RefreshFallbackMetadata(MediaItem mediaItem);
|
||||
Task<Unit> RefreshFallbackMetadata(Show televisionShow, string showFolder);
|
||||
Task<bool> RefreshSidecarMetadata(MediaItem mediaItem, string path);
|
||||
Task<bool> RefreshSidecarMetadata(Show televisionShow, string showFolder);
|
||||
Task<bool> RefreshFallbackMetadata(MediaItem mediaItem);
|
||||
Task<bool> RefreshFallbackMetadata(Show televisionShow, string showFolder);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using LanguageExt;
|
||||
|
||||
namespace ErsatzTV.Core.Interfaces.Metadata
|
||||
{
|
||||
public interface ILocalStatisticsProvider
|
||||
{
|
||||
Task<bool> RefreshStatistics(string ffprobePath, MediaItem mediaItem);
|
||||
Task<Either<BaseError, bool>> RefreshStatistics(string ffprobePath, MediaItem mediaItem);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,8 +10,8 @@ namespace ErsatzTV.Core.Interfaces.Plex
|
||||
Task<string> GetClientIdentifier();
|
||||
Task<List<PlexUserAuthToken>> GetUserAuthTokens();
|
||||
Task<Unit> UpsertUserAuthToken(PlexUserAuthToken userAuthToken);
|
||||
Task<List<PlexServerAuthToken>> GetServerAuthTokens();
|
||||
Task<Option<PlexServerAuthToken>> GetServerAuthToken(string clientIdentifier);
|
||||
Task<Unit> UpsertServerAuthToken(PlexServerAuthToken serverAuthToken);
|
||||
Task<Unit> DeleteAll();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,9 +12,31 @@ namespace ErsatzTV.Core.Interfaces.Plex
|
||||
PlexConnection connection,
|
||||
PlexServerAuthToken token);
|
||||
|
||||
Task<Either<BaseError, List<PlexMovie>>> GetLibraryContents(
|
||||
Task<Either<BaseError, List<PlexMovie>>> GetMovieLibraryContents(
|
||||
PlexLibrary library,
|
||||
PlexConnection connection,
|
||||
PlexServerAuthToken token);
|
||||
|
||||
Task<Either<BaseError, List<PlexShow>>> GetShowLibraryContents(
|
||||
PlexLibrary library,
|
||||
PlexConnection connection,
|
||||
PlexServerAuthToken token);
|
||||
|
||||
Task<Either<BaseError, List<PlexSeason>>> GetShowSeasons(
|
||||
PlexLibrary library,
|
||||
PlexShow show,
|
||||
PlexConnection connection,
|
||||
PlexServerAuthToken token);
|
||||
|
||||
Task<Either<BaseError, List<PlexEpisode>>> GetSeasonEpisodes(
|
||||
PlexLibrary library,
|
||||
PlexSeason season,
|
||||
PlexConnection connection,
|
||||
PlexServerAuthToken token);
|
||||
|
||||
Task<Either<BaseError, MediaVersion>> GetStatistics(
|
||||
string key,
|
||||
PlexConnection connection,
|
||||
PlexServerAuthToken token);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Plex;
|
||||
using LanguageExt;
|
||||
|
||||
namespace ErsatzTV.Core.Interfaces.Plex
|
||||
{
|
||||
public interface IPlexTelevisionLibraryScanner
|
||||
{
|
||||
Task<Either<BaseError, Unit>> ScanLibrary(
|
||||
PlexConnection connection,
|
||||
PlexServerAuthToken token,
|
||||
PlexLibrary plexMediaSourceLibrary);
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ namespace ErsatzTV.Core.Interfaces.Repositories
|
||||
Task<List<LibraryPath>> GetLocalPaths(int libraryId);
|
||||
Task<Option<LibraryPath>> GetPath(int libraryPathId);
|
||||
Task<int> CountMediaItemsByPath(int libraryPathId);
|
||||
Task<List<int>> GetMediaIdsByLocalPath(int libraryPathId);
|
||||
Task DeleteLocalPath(int libraryPathId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,10 +13,12 @@ namespace ErsatzTV.Core.Interfaces.Repositories
|
||||
Task<Option<Collection>> Get(int id);
|
||||
Task<Option<Collection>> GetCollectionWithItems(int id);
|
||||
Task<Option<Collection>> GetCollectionWithItemsUntracked(int id);
|
||||
Task<Option<Collection>> GetCollectionWithCollectionItemsUntracked(int id);
|
||||
Task<List<Collection>> GetAll();
|
||||
Task<Option<List<MediaItem>>> GetItems(int id);
|
||||
Task<bool> Update(Collection collection);
|
||||
Task Delete(int collectionId);
|
||||
Task<List<int>> PlayoutIdsUsingCollection(int collectionId);
|
||||
Task<bool> IsCustomPlaybackOrder(int collectionId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,10 +20,23 @@ namespace ErsatzTV.Core.Interfaces.Repositories
|
||||
Task<List<PlexPathReplacement>> GetPlexPathReplacementsByLibraryId(int plexLibraryPathId);
|
||||
Task<int> CountMediaItems(int id);
|
||||
Task Update(LocalMediaSource localMediaSource);
|
||||
Task Update(PlexMediaSource plexMediaSource);
|
||||
Task Update(PlexMediaSource plexMediaSource, List<PlexConnection> toAdd, List<PlexConnection> toDelete);
|
||||
|
||||
Task<Unit> UpdateLibraries(
|
||||
int plexMediaSourceId,
|
||||
List<PlexLibrary> toAdd,
|
||||
List<PlexLibrary> toDelete);
|
||||
|
||||
Task<Unit> UpdatePathReplacements(
|
||||
int plexMediaSourceId,
|
||||
List<PlexPathReplacement> toAdd,
|
||||
List<PlexPathReplacement> toUpdate,
|
||||
List<PlexPathReplacement> toDelete);
|
||||
|
||||
Task Update(PlexLibrary plexMediaSourceLibrary);
|
||||
Task Delete(int id);
|
||||
Task DisablePlexLibrarySync(List<int> libraryIds);
|
||||
Task Delete(int mediaSourceId);
|
||||
Task<List<int>> DeleteAllPlex();
|
||||
Task<List<int>> DisablePlexLibrarySync(List<int> libraryIds);
|
||||
Task EnablePlexLibrarySync(IEnumerable<int> libraryIds);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using LanguageExt;
|
||||
|
||||
namespace ErsatzTV.Core.Interfaces.Repositories
|
||||
{
|
||||
public interface IMetadataRepository
|
||||
{
|
||||
Task<bool> RemoveGenre(Genre genre);
|
||||
Task<bool> RemoveTag(Tag tag);
|
||||
Task<bool> RemoveStudio(Studio studio);
|
||||
Task<bool> Update(Domain.Metadata metadata);
|
||||
Task<bool> Add(Domain.Metadata metadata);
|
||||
Task<bool> UpdateLocalStatistics(MediaVersion mediaVersion);
|
||||
Task<bool> UpdatePlexStatistics(MediaVersion mediaVersion);
|
||||
Task<Unit> UpdateArtworkPath(Artwork artwork);
|
||||
Task<Unit> AddArtwork(Domain.Metadata metadata, Artwork artwork);
|
||||
Task<Unit> RemoveArtwork(Domain.Metadata metadata, ArtworkKind artworkKind);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Metadata;
|
||||
using LanguageExt;
|
||||
|
||||
namespace ErsatzTV.Core.Interfaces.Repositories
|
||||
@@ -9,12 +10,17 @@ namespace ErsatzTV.Core.Interfaces.Repositories
|
||||
{
|
||||
Task<bool> AllMoviesExist(List<int> movieIds);
|
||||
Task<Option<Movie>> GetMovie(int movieId);
|
||||
Task<Either<BaseError, Movie>> GetOrAdd(LibraryPath libraryPath, string path);
|
||||
Task<Either<BaseError, PlexMovie>> GetOrAdd(PlexLibrary library, PlexMovie item);
|
||||
Task<bool> Update(Movie movie);
|
||||
Task<Either<BaseError, MediaItemScanResult<Movie>>> GetOrAdd(LibraryPath libraryPath, string path);
|
||||
Task<Either<BaseError, MediaItemScanResult<PlexMovie>>> GetOrAdd(PlexLibrary library, PlexMovie item);
|
||||
Task<int> GetMovieCount();
|
||||
Task<List<MovieMetadata>> GetPagedMovies(int pageNumber, int pageSize);
|
||||
Task<List<MovieMetadata>> GetMoviesForCards(List<int> ids);
|
||||
Task<IEnumerable<string>> FindMoviePaths(LibraryPath libraryPath);
|
||||
Task<Unit> DeleteByPath(LibraryPath libraryPath, string path);
|
||||
Task<List<int>> DeleteByPath(LibraryPath libraryPath, string path);
|
||||
Task<bool> AddGenre(MovieMetadata metadata, Genre genre);
|
||||
Task<bool> AddTag(MovieMetadata metadata, Tag tag);
|
||||
Task<bool> AddStudio(MovieMetadata metadata, Studio studio);
|
||||
Task<List<int>> RemoveMissingPlexMovies(PlexLibrary library, List<string> movieKeys);
|
||||
Task<bool> UpdateSortTitle(MovieMetadata movieMetadata);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using LanguageExt;
|
||||
|
||||
namespace ErsatzTV.Core.Interfaces.Repositories
|
||||
{
|
||||
public interface ISearchRepository
|
||||
{
|
||||
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);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Metadata;
|
||||
using LanguageExt;
|
||||
|
||||
namespace ErsatzTV.Core.Interfaces.Repositories
|
||||
@@ -8,13 +9,11 @@ namespace ErsatzTV.Core.Interfaces.Repositories
|
||||
public interface ITelevisionRepository
|
||||
{
|
||||
Task<bool> AllShowsExist(List<int> showIds);
|
||||
Task<bool> Update(Show show);
|
||||
Task<bool> Update(Season season);
|
||||
Task<bool> Update(Episode episode);
|
||||
Task<List<Show>> GetAllShows();
|
||||
Task<Option<Show>> GetShow(int showId);
|
||||
Task<int> GetShowCount();
|
||||
Task<List<ShowMetadata>> GetPagedShows(int pageNumber, int pageSize);
|
||||
Task<List<ShowMetadata>> GetShowsForCards(List<int> ids);
|
||||
Task<List<Episode>> GetShowItems(int showId);
|
||||
Task<List<Season>> GetAllSeasons();
|
||||
Task<Option<Season>> GetSeason(int seasonId);
|
||||
@@ -25,12 +24,27 @@ namespace ErsatzTV.Core.Interfaces.Repositories
|
||||
Task<int> GetEpisodeCount(int seasonId);
|
||||
Task<List<EpisodeMetadata>> GetPagedEpisodes(int seasonId, int pageNumber, int pageSize);
|
||||
Task<Option<Show>> GetShowByMetadata(int libraryPathId, ShowMetadata metadata);
|
||||
Task<Either<BaseError, Show>> AddShow(int libraryPathId, string showFolder, ShowMetadata metadata);
|
||||
|
||||
Task<Either<BaseError, MediaItemScanResult<Show>>> AddShow(
|
||||
int libraryPathId,
|
||||
string showFolder,
|
||||
ShowMetadata metadata);
|
||||
|
||||
Task<Either<BaseError, Season>> GetOrAddSeason(Show show, int libraryPathId, int seasonNumber);
|
||||
Task<Either<BaseError, Episode>> GetOrAddEpisode(Season season, LibraryPath libraryPath, string path);
|
||||
Task<IEnumerable<string>> FindEpisodePaths(LibraryPath libraryPath);
|
||||
Task<Unit> DeleteByPath(LibraryPath libraryPath, string path);
|
||||
Task<Unit> DeleteEmptySeasons(LibraryPath libraryPath);
|
||||
Task<Unit> DeleteEmptyShows(LibraryPath libraryPath);
|
||||
Task<List<int>> DeleteEmptyShows(LibraryPath libraryPath);
|
||||
Task<Either<BaseError, MediaItemScanResult<PlexShow>>> GetOrAddPlexShow(PlexLibrary library, PlexShow item);
|
||||
Task<Either<BaseError, PlexSeason>> GetOrAddPlexSeason(PlexLibrary library, PlexSeason item);
|
||||
Task<Either<BaseError, PlexEpisode>> GetOrAddPlexEpisode(PlexLibrary library, PlexEpisode item);
|
||||
Task<bool> AddGenre(ShowMetadata metadata, Genre genre);
|
||||
Task<bool> AddTag(ShowMetadata metadata, Tag tag);
|
||||
Task<bool> AddStudio(ShowMetadata metadata, Studio studio);
|
||||
Task<List<int>> RemoveMissingPlexShows(PlexLibrary library, List<string> showKeys);
|
||||
Task<Unit> RemoveMissingPlexSeasons(string showKey, List<string> seasonKeys);
|
||||
Task<Unit> RemoveMissingPlexEpisodes(string seasonKey, List<string> episodeKeys);
|
||||
Task<Unit> SetEpisodeNumber(Episode episode, int episodeNumber);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Search;
|
||||
using LanguageExt;
|
||||
|
||||
namespace ErsatzTV.Core.Interfaces.Search
|
||||
{
|
||||
public interface ISearchIndex
|
||||
{
|
||||
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<Unit> RemoveItems(List<int> ids);
|
||||
Task<SearchResult> Search(string query, int skip, int limit, string searchField = "");
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,8 @@ using System.Linq;
|
||||
using System.Text;
|
||||
using System.Xml;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using LanguageExt;
|
||||
using LanguageExt.UnsafeValueAccess;
|
||||
using static LanguageExt.Prelude;
|
||||
|
||||
namespace ErsatzTV.Core.Iptv
|
||||
@@ -64,11 +66,19 @@ namespace ErsatzTV.Core.Iptv
|
||||
{
|
||||
Movie m => m.MovieMetadata.HeadOrNone().Map(mm => mm.Title ?? string.Empty)
|
||||
.IfNone("[unknown movie]"),
|
||||
Episode e => e.EpisodeMetadata.HeadOrNone().Map(em => em.Title ?? string.Empty)
|
||||
.IfNone("[unknown episode]"),
|
||||
Episode e => e.Season.Show.ShowMetadata.HeadOrNone().Map(em => em.Title ?? string.Empty)
|
||||
.IfNone("[unknown show]"),
|
||||
_ => "[unknown]"
|
||||
};
|
||||
|
||||
string subtitle = playoutItem.MediaItem switch
|
||||
{
|
||||
Episode e => e.EpisodeMetadata.HeadOrNone().Match(
|
||||
em => em.Title ?? string.Empty,
|
||||
() => string.Empty),
|
||||
_ => string.Empty
|
||||
};
|
||||
|
||||
string description = playoutItem.MediaItem switch
|
||||
{
|
||||
Movie m => m.MovieMetadata.HeadOrNone().Map(mm => mm.Plot ?? string.Empty).IfNone(string.Empty),
|
||||
@@ -89,20 +99,79 @@ namespace ErsatzTV.Core.Iptv
|
||||
xml.WriteAttributeString("stop", stop);
|
||||
xml.WriteAttributeString("channel", channel.Number);
|
||||
|
||||
if (playoutItem.MediaItem is Movie movie)
|
||||
{
|
||||
xml.WriteStartElement("category");
|
||||
xml.WriteAttributeString("lang", "en");
|
||||
xml.WriteString("Movie");
|
||||
xml.WriteEndElement(); // category
|
||||
|
||||
Option<MovieMetadata> maybeMetadata = movie.MovieMetadata.HeadOrNone();
|
||||
if (maybeMetadata.IsSome)
|
||||
{
|
||||
MovieMetadata metadata = maybeMetadata.ValueUnsafe();
|
||||
|
||||
if (metadata.Year.HasValue)
|
||||
{
|
||||
xml.WriteStartElement("date");
|
||||
xml.WriteString(metadata.Year.Value.ToString());
|
||||
xml.WriteEndElement(); // date
|
||||
}
|
||||
|
||||
string poster = Optional(metadata.Artwork).Flatten()
|
||||
.Filter(a => a.ArtworkKind == ArtworkKind.Poster)
|
||||
.HeadOrNone()
|
||||
.Match(
|
||||
artwork => $"{_scheme}://{_host}/artwork/posters/{artwork.Path}",
|
||||
() => string.Empty);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(poster))
|
||||
{
|
||||
xml.WriteStartElement("icon");
|
||||
xml.WriteAttributeString("src", poster);
|
||||
xml.WriteEndElement(); // icon
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
xml.WriteStartElement("title");
|
||||
xml.WriteAttributeString("lang", "en");
|
||||
xml.WriteString(title);
|
||||
xml.WriteEndElement(); // title
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(subtitle))
|
||||
{
|
||||
xml.WriteStartElement("sub-title");
|
||||
xml.WriteAttributeString("lang", "en");
|
||||
xml.WriteString(subtitle);
|
||||
xml.WriteEndElement(); // subtitle
|
||||
}
|
||||
|
||||
xml.WriteStartElement("previously-shown");
|
||||
xml.WriteEndElement(); // previously-shown
|
||||
|
||||
xml.WriteStartElement("sub-title");
|
||||
xml.WriteAttributeString("lang", "en");
|
||||
xml.WriteEndElement(); // sub-title
|
||||
|
||||
if (playoutItem.MediaItem is Episode episode)
|
||||
{
|
||||
Option<ShowMetadata> maybeMetadata =
|
||||
Optional(episode.Season?.Show?.ShowMetadata.HeadOrNone()).Flatten();
|
||||
if (maybeMetadata.IsSome)
|
||||
{
|
||||
ShowMetadata metadata = maybeMetadata.ValueUnsafe();
|
||||
string poster = Optional(metadata.Artwork).Flatten()
|
||||
.Filter(a => a.ArtworkKind == ArtworkKind.Poster)
|
||||
.HeadOrNone()
|
||||
.Match(
|
||||
artwork => $"{_scheme}://{_host}/artwork/posters/{artwork.Path}",
|
||||
() => string.Empty);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(poster))
|
||||
{
|
||||
xml.WriteStartElement("icon");
|
||||
xml.WriteAttributeString("src", poster);
|
||||
xml.WriteEndElement(); // icon
|
||||
}
|
||||
}
|
||||
|
||||
int s = Optional(episode.Season?.SeasonNumber).IfNone(0);
|
||||
int e = episode.EpisodeNumber;
|
||||
if (s > 0 && e > 0)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Text.RegularExpressions;
|
||||
using ErsatzTV.Core.Domain;
|
||||
@@ -22,7 +23,7 @@ namespace ErsatzTV.Core.Metadata
|
||||
string path = episode.MediaVersions.Head().MediaFiles.Head().Path;
|
||||
string fileName = Path.GetFileName(path);
|
||||
var metadata = new EpisodeMetadata
|
||||
{ MetadataKind = MetadataKind.Fallback, Title = fileName ?? path };
|
||||
{ MetadataKind = MetadataKind.Fallback, Title = fileName ?? path, DateAdded = DateTime.UtcNow };
|
||||
return fileName != null ? GetEpisodeMetadata(fileName, metadata) : Tuple(metadata, 0);
|
||||
}
|
||||
|
||||
@@ -47,6 +48,16 @@ namespace ErsatzTV.Core.Metadata
|
||||
return title.Substring(4);
|
||||
}
|
||||
|
||||
if (title.StartsWith("a ", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return title.Substring(2);
|
||||
}
|
||||
|
||||
if (title.StartsWith("an ", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return title.Substring(3);
|
||||
}
|
||||
|
||||
if (title.StartsWith("Æ"))
|
||||
{
|
||||
return title.Replace("Æ", "E");
|
||||
@@ -64,6 +75,7 @@ namespace ErsatzTV.Core.Metadata
|
||||
if (match.Success)
|
||||
{
|
||||
metadata.Title = match.Groups[1].Value;
|
||||
metadata.DateUpdated = DateTime.UtcNow;
|
||||
return Tuple(metadata, int.Parse(match.Groups[3].Value));
|
||||
}
|
||||
}
|
||||
@@ -86,6 +98,10 @@ namespace ErsatzTV.Core.Metadata
|
||||
metadata.Title = match.Groups[1].Value;
|
||||
metadata.Year = int.Parse(match.Groups[2].Value);
|
||||
metadata.ReleaseDate = new DateTime(int.Parse(match.Groups[2].Value), 1, 1);
|
||||
metadata.Genres = new List<Genre>();
|
||||
metadata.Tags = new List<Tag>();
|
||||
metadata.Studios = new List<Studio>();
|
||||
metadata.DateUpdated = DateTime.UtcNow;
|
||||
}
|
||||
}
|
||||
catch (Exception)
|
||||
@@ -107,6 +123,7 @@ namespace ErsatzTV.Core.Metadata
|
||||
metadata.Title = match.Groups[1].Value;
|
||||
metadata.Year = int.Parse(match.Groups[2].Value);
|
||||
metadata.ReleaseDate = new DateTime(int.Parse(match.Groups[2].Value), 1, 1);
|
||||
metadata.DateUpdated = DateTime.UtcNow;
|
||||
}
|
||||
}
|
||||
catch (Exception)
|
||||
|
||||
@@ -11,6 +11,16 @@ namespace ErsatzTV.Core.Metadata
|
||||
{
|
||||
public class LocalFileSystem : ILocalFileSystem
|
||||
{
|
||||
public Unit EnsureFolderExists(string folder)
|
||||
{
|
||||
if (!Directory.Exists(folder))
|
||||
{
|
||||
Directory.CreateDirectory(folder);
|
||||
}
|
||||
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
public DateTime GetLastWriteTime(string path) =>
|
||||
Try(File.GetLastWriteTimeUtc(path)).IfFail(() => DateTime.MinValue);
|
||||
|
||||
|
||||
@@ -7,9 +7,9 @@ using System.Threading.Tasks;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Images;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using LanguageExt;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using static LanguageExt.Prelude;
|
||||
|
||||
namespace ErsatzTV.Core.Metadata
|
||||
{
|
||||
@@ -48,27 +48,32 @@ namespace ErsatzTV.Core.Metadata
|
||||
private readonly ILocalFileSystem _localFileSystem;
|
||||
private readonly ILocalStatisticsProvider _localStatisticsProvider;
|
||||
private readonly ILogger _logger;
|
||||
private readonly IMetadataRepository _metadataRepository;
|
||||
|
||||
static LocalFolderScanner() => Crypto = new SHA1CryptoServiceProvider();
|
||||
|
||||
protected LocalFolderScanner(
|
||||
ILocalFileSystem localFileSystem,
|
||||
ILocalStatisticsProvider localStatisticsProvider,
|
||||
IMetadataRepository metadataRepository,
|
||||
IImageCache imageCache,
|
||||
ILogger logger)
|
||||
{
|
||||
_localFileSystem = localFileSystem;
|
||||
_localStatisticsProvider = localStatisticsProvider;
|
||||
_metadataRepository = metadataRepository;
|
||||
_imageCache = imageCache;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
protected async Task<Either<BaseError, T>> UpdateStatistics<T>(T mediaItem, string ffprobePath)
|
||||
protected async Task<Either<BaseError, MediaItemScanResult<T>>> UpdateStatistics<T>(
|
||||
MediaItemScanResult<T> mediaItem,
|
||||
string ffprobePath)
|
||||
where T : MediaItem
|
||||
{
|
||||
try
|
||||
{
|
||||
MediaVersion version = mediaItem switch
|
||||
MediaVersion version = mediaItem.Item switch
|
||||
{
|
||||
Movie m => m.MediaVersions.Head(),
|
||||
Episode e => e.MediaVersions.Head(),
|
||||
@@ -80,7 +85,22 @@ namespace ErsatzTV.Core.Metadata
|
||||
if (version.DateUpdated < _localFileSystem.GetLastWriteTime(path))
|
||||
{
|
||||
_logger.LogDebug("Refreshing {Attribute} for {Path}", "Statistics", path);
|
||||
await _localStatisticsProvider.RefreshStatistics(ffprobePath, mediaItem);
|
||||
Either<BaseError, bool> refreshResult =
|
||||
await _localStatisticsProvider.RefreshStatistics(ffprobePath, mediaItem.Item);
|
||||
refreshResult.Match(
|
||||
result =>
|
||||
{
|
||||
if (result)
|
||||
{
|
||||
mediaItem.IsUpdated = true;
|
||||
}
|
||||
},
|
||||
error =>
|
||||
_logger.LogWarning(
|
||||
"Unable to refresh {Attribute} for media item {Path}. Error: {Error}",
|
||||
"Statistics",
|
||||
path,
|
||||
error.Value));
|
||||
}
|
||||
|
||||
return mediaItem;
|
||||
@@ -91,17 +111,16 @@ namespace ErsatzTV.Core.Metadata
|
||||
}
|
||||
}
|
||||
|
||||
protected bool RefreshArtwork(string artworkFile, Domain.Metadata metadata, ArtworkKind artworkKind)
|
||||
protected async Task<bool> RefreshArtwork(string artworkFile, Domain.Metadata metadata, ArtworkKind artworkKind)
|
||||
{
|
||||
DateTime lastWriteTime = _localFileSystem.GetLastWriteTime(artworkFile);
|
||||
|
||||
metadata.Artwork ??= new List<Artwork>();
|
||||
|
||||
Option<Artwork> maybeArtwork =
|
||||
Optional(metadata.Artwork).Flatten().FirstOrDefault(a => a.ArtworkKind == artworkKind);
|
||||
Option<Artwork> maybeArtwork = metadata.Artwork.FirstOrDefault(a => a.ArtworkKind == artworkKind);
|
||||
|
||||
bool shouldRefresh = maybeArtwork.Match(
|
||||
artwork => artwork.DateUpdated < lastWriteTime,
|
||||
artwork => lastWriteTime.Subtract(artwork.DateUpdated) > TimeSpan.FromSeconds(1),
|
||||
true);
|
||||
|
||||
if (shouldRefresh)
|
||||
@@ -109,13 +128,14 @@ namespace ErsatzTV.Core.Metadata
|
||||
_logger.LogDebug("Refreshing {Attribute} from {Path}", artworkKind, artworkFile);
|
||||
string cacheName = _imageCache.CopyArtworkToCache(artworkFile, artworkKind);
|
||||
|
||||
maybeArtwork.Match(
|
||||
artwork =>
|
||||
await maybeArtwork.Match(
|
||||
async artwork =>
|
||||
{
|
||||
artwork.Path = cacheName;
|
||||
artwork.DateUpdated = lastWriteTime;
|
||||
await _metadataRepository.UpdateArtworkPath(artwork);
|
||||
},
|
||||
() =>
|
||||
async () =>
|
||||
{
|
||||
var artwork = new Artwork
|
||||
{
|
||||
@@ -124,8 +144,8 @@ namespace ErsatzTV.Core.Metadata
|
||||
DateUpdated = lastWriteTime,
|
||||
ArtworkKind = artworkKind
|
||||
};
|
||||
|
||||
metadata.Artwork.Add(artwork);
|
||||
await _metadataRepository.AddArtwork(metadata, artwork);
|
||||
});
|
||||
|
||||
return true;
|
||||
|
||||
@@ -22,17 +22,20 @@ namespace ErsatzTV.Core.Metadata
|
||||
private readonly ILocalFileSystem _localFileSystem;
|
||||
private readonly ILogger<LocalMetadataProvider> _logger;
|
||||
|
||||
private readonly IMediaItemRepository _mediaItemRepository;
|
||||
private readonly IMetadataRepository _metadataRepository;
|
||||
private readonly IMovieRepository _movieRepository;
|
||||
private readonly ITelevisionRepository _televisionRepository;
|
||||
|
||||
public LocalMetadataProvider(
|
||||
IMediaItemRepository mediaItemRepository,
|
||||
IMetadataRepository metadataRepository,
|
||||
IMovieRepository movieRepository,
|
||||
ITelevisionRepository televisionRepository,
|
||||
IFallbackMetadataProvider fallbackMetadataProvider,
|
||||
ILocalFileSystem localFileSystem,
|
||||
ILogger<LocalMetadataProvider> logger)
|
||||
{
|
||||
_mediaItemRepository = mediaItemRepository;
|
||||
_metadataRepository = metadataRepository;
|
||||
_movieRepository = movieRepository;
|
||||
_televisionRepository = televisionRepository;
|
||||
_fallbackMetadataProvider = fallbackMetadataProvider;
|
||||
_localFileSystem = localFileSystem;
|
||||
@@ -48,7 +51,12 @@ namespace ErsatzTV.Core.Metadata
|
||||
maybeMetadata = await LoadTelevisionShowMetadata(nfoFileName);
|
||||
}
|
||||
|
||||
return maybeMetadata.IfNone(
|
||||
return maybeMetadata.Match(
|
||||
metadata =>
|
||||
{
|
||||
metadata.SortTitle = _fallbackMetadataProvider.GetSortTitle(metadata.Title);
|
||||
return metadata;
|
||||
},
|
||||
() =>
|
||||
{
|
||||
ShowMetadata metadata = _fallbackMetadataProvider.GetFallbackMetadataForShow(showFolder);
|
||||
@@ -57,38 +65,48 @@ namespace ErsatzTV.Core.Metadata
|
||||
});
|
||||
}
|
||||
|
||||
public Task<Unit> RefreshSidecarMetadata(MediaItem mediaItem, string path) =>
|
||||
public Task<bool> RefreshSidecarMetadata(MediaItem mediaItem, string path) =>
|
||||
mediaItem switch
|
||||
{
|
||||
Episode e => LoadMetadata(e, path)
|
||||
.Bind(maybeMetadata => maybeMetadata.IfSomeAsync(metadata => ApplyMetadataUpdate(e, metadata))),
|
||||
.Bind(
|
||||
maybeMetadata => maybeMetadata.Match(
|
||||
metadata => ApplyMetadataUpdate(e, metadata),
|
||||
() => Task.FromResult(false))),
|
||||
Movie m => LoadMetadata(m, path)
|
||||
.Bind(maybeMetadata => maybeMetadata.IfSomeAsync(metadata => ApplyMetadataUpdate(m, metadata))),
|
||||
_ => Task.FromResult(Unit.Default)
|
||||
.Bind(
|
||||
maybeMetadata => maybeMetadata.Match(
|
||||
metadata => ApplyMetadataUpdate(m, metadata),
|
||||
() => Task.FromResult(false))),
|
||||
_ => Task.FromResult(false)
|
||||
};
|
||||
|
||||
public Task<Unit> RefreshSidecarMetadata(Show televisionShow, string showFolder) =>
|
||||
public Task<bool> RefreshSidecarMetadata(Show televisionShow, string showFolder) =>
|
||||
LoadMetadata(televisionShow, showFolder).Bind(
|
||||
maybeMetadata => maybeMetadata.IfSomeAsync(metadata => ApplyMetadataUpdate(televisionShow, metadata)));
|
||||
maybeMetadata => maybeMetadata.Match(
|
||||
metadata => ApplyMetadataUpdate(televisionShow, metadata),
|
||||
() => Task.FromResult(false)));
|
||||
|
||||
public Task<Unit> RefreshFallbackMetadata(MediaItem mediaItem) =>
|
||||
public Task<bool> RefreshFallbackMetadata(MediaItem mediaItem) =>
|
||||
mediaItem switch
|
||||
{
|
||||
Episode e => ApplyMetadataUpdate(e, _fallbackMetadataProvider.GetFallbackMetadata(e))
|
||||
.ToUnit(),
|
||||
Movie m => ApplyMetadataUpdate(m, _fallbackMetadataProvider.GetFallbackMetadata(m)).ToUnit(),
|
||||
_ => Task.FromResult(Unit.Default)
|
||||
Episode e => ApplyMetadataUpdate(e, _fallbackMetadataProvider.GetFallbackMetadata(e)),
|
||||
Movie m => ApplyMetadataUpdate(m, _fallbackMetadataProvider.GetFallbackMetadata(m)),
|
||||
_ => Task.FromResult(false)
|
||||
};
|
||||
|
||||
public Task<Unit> RefreshFallbackMetadata(Show televisionShow, string showFolder) =>
|
||||
ApplyMetadataUpdate(televisionShow, _fallbackMetadataProvider.GetFallbackMetadataForShow(showFolder))
|
||||
.ToUnit();
|
||||
public Task<bool> RefreshFallbackMetadata(Show televisionShow, string showFolder) =>
|
||||
ApplyMetadataUpdate(televisionShow, _fallbackMetadataProvider.GetFallbackMetadataForShow(showFolder));
|
||||
|
||||
private async Task ApplyMetadataUpdate(Episode episode, Tuple<EpisodeMetadata, int> metadataEpisodeNumber)
|
||||
private async Task<bool> ApplyMetadataUpdate(Episode episode, Tuple<EpisodeMetadata, int> metadataEpisodeNumber)
|
||||
{
|
||||
(EpisodeMetadata metadata, int episodeNumber) = metadataEpisodeNumber;
|
||||
episode.EpisodeNumber = episodeNumber;
|
||||
Optional(episode.EpisodeMetadata).Flatten().HeadOrNone().Match(
|
||||
if (episode.EpisodeNumber != episodeNumber)
|
||||
{
|
||||
await _televisionRepository.SetEpisodeNumber(episode, episodeNumber);
|
||||
}
|
||||
|
||||
await Optional(episode.EpisodeMetadata).Flatten().HeadOrNone().Match(
|
||||
existing =>
|
||||
{
|
||||
existing.Outline = metadata.Outline;
|
||||
@@ -104,22 +122,26 @@ namespace ErsatzTV.Core.Metadata
|
||||
existing.SortTitle = string.IsNullOrWhiteSpace(metadata.SortTitle)
|
||||
? _fallbackMetadataProvider.GetSortTitle(metadata.Title)
|
||||
: metadata.SortTitle;
|
||||
|
||||
return _metadataRepository.Update(existing);
|
||||
},
|
||||
() =>
|
||||
{
|
||||
metadata.SortTitle = string.IsNullOrWhiteSpace(metadata.SortTitle)
|
||||
? _fallbackMetadataProvider.GetSortTitle(metadata.Title)
|
||||
: metadata.SortTitle;
|
||||
metadata.EpisodeId = episode.Id;
|
||||
episode.EpisodeMetadata = new List<EpisodeMetadata> { metadata };
|
||||
|
||||
return _metadataRepository.Add(metadata);
|
||||
});
|
||||
|
||||
await _televisionRepository.Update(episode);
|
||||
return true;
|
||||
}
|
||||
|
||||
private async Task ApplyMetadataUpdate(Movie movie, MovieMetadata metadata)
|
||||
{
|
||||
private Task<bool> ApplyMetadataUpdate(Movie movie, MovieMetadata metadata) =>
|
||||
Optional(movie.MovieMetadata).Flatten().HeadOrNone().Match(
|
||||
existing =>
|
||||
async existing =>
|
||||
{
|
||||
existing.Outline = metadata.Outline;
|
||||
existing.Plot = metadata.Plot;
|
||||
@@ -139,41 +161,62 @@ namespace ErsatzTV.Core.Metadata
|
||||
.ToList())
|
||||
{
|
||||
existing.Genres.Remove(genre);
|
||||
await _metadataRepository.RemoveGenre(genre);
|
||||
}
|
||||
|
||||
foreach (Genre genre in metadata.Genres.Filter(g => existing.Genres.All(g2 => g2.Name != g.Name))
|
||||
.ToList())
|
||||
{
|
||||
existing.Genres.Add(genre);
|
||||
await _movieRepository.AddGenre(existing, genre);
|
||||
}
|
||||
|
||||
foreach (Tag tag in existing.Tags.Filter(t => metadata.Tags.All(t2 => t2.Name != t.Name))
|
||||
.ToList())
|
||||
{
|
||||
existing.Tags.Remove(tag);
|
||||
await _metadataRepository.RemoveTag(tag);
|
||||
}
|
||||
|
||||
foreach (Tag tag in metadata.Tags.Filter(t => existing.Tags.All(t2 => t2.Name != t.Name))
|
||||
.ToList())
|
||||
{
|
||||
existing.Tags.Add(tag);
|
||||
await _movieRepository.AddTag(existing, tag);
|
||||
}
|
||||
|
||||
foreach (Studio studio in existing.Studios
|
||||
.Filter(s => metadata.Studios.All(s2 => s2.Name != s.Name))
|
||||
.ToList())
|
||||
{
|
||||
existing.Studios.Remove(studio);
|
||||
await _metadataRepository.RemoveStudio(studio);
|
||||
}
|
||||
|
||||
foreach (Studio studio in metadata.Studios
|
||||
.Filter(s => existing.Studios.All(s2 => s2.Name != s.Name))
|
||||
.ToList())
|
||||
{
|
||||
existing.Studios.Add(studio);
|
||||
await _movieRepository.AddStudio(existing, studio);
|
||||
}
|
||||
|
||||
return await _metadataRepository.Update(existing);
|
||||
},
|
||||
() =>
|
||||
async () =>
|
||||
{
|
||||
metadata.SortTitle = string.IsNullOrWhiteSpace(metadata.SortTitle)
|
||||
? _fallbackMetadataProvider.GetSortTitle(metadata.Title)
|
||||
: metadata.SortTitle;
|
||||
metadata.MovieId = movie.Id;
|
||||
movie.MovieMetadata = new List<MovieMetadata> { metadata };
|
||||
|
||||
return await _metadataRepository.Add(metadata);
|
||||
});
|
||||
|
||||
await _mediaItemRepository.Update(movie);
|
||||
}
|
||||
|
||||
private async Task ApplyMetadataUpdate(Show show, ShowMetadata metadata)
|
||||
{
|
||||
private Task<bool> ApplyMetadataUpdate(Show show, ShowMetadata metadata) =>
|
||||
Optional(show.ShowMetadata).Flatten().HeadOrNone().Match(
|
||||
existing =>
|
||||
async existing =>
|
||||
{
|
||||
existing.Outline = metadata.Outline;
|
||||
existing.Plot = metadata.Plot;
|
||||
@@ -193,36 +236,58 @@ namespace ErsatzTV.Core.Metadata
|
||||
.ToList())
|
||||
{
|
||||
existing.Genres.Remove(genre);
|
||||
await _metadataRepository.RemoveGenre(genre);
|
||||
}
|
||||
|
||||
foreach (Genre genre in metadata.Genres.Filter(g => existing.Genres.All(g2 => g2.Name != g.Name))
|
||||
.ToList())
|
||||
{
|
||||
existing.Genres.Add(genre);
|
||||
await _televisionRepository.AddGenre(existing, genre);
|
||||
}
|
||||
|
||||
foreach (Tag tag in existing.Tags.Filter(t => metadata.Tags.All(t2 => t2.Name != t.Name))
|
||||
.ToList())
|
||||
{
|
||||
existing.Tags.Remove(tag);
|
||||
await _metadataRepository.RemoveTag(tag);
|
||||
}
|
||||
|
||||
foreach (Tag tag in metadata.Tags.Filter(t => existing.Tags.All(t2 => t2.Name != t.Name))
|
||||
.ToList())
|
||||
{
|
||||
existing.Tags.Add(tag);
|
||||
await _televisionRepository.AddTag(existing, tag);
|
||||
}
|
||||
|
||||
foreach (Studio studio in existing.Studios
|
||||
.Filter(s => metadata.Studios.All(s2 => s2.Name != s.Name))
|
||||
.ToList())
|
||||
{
|
||||
existing.Studios.Remove(studio);
|
||||
await _metadataRepository.RemoveStudio(studio);
|
||||
}
|
||||
|
||||
foreach (Studio studio in metadata.Studios
|
||||
.Filter(s => existing.Studios.All(s2 => s2.Name != s.Name))
|
||||
.ToList())
|
||||
{
|
||||
existing.Studios.Add(studio);
|
||||
await _televisionRepository.AddStudio(existing, studio);
|
||||
}
|
||||
|
||||
return await _metadataRepository.Update(existing);
|
||||
},
|
||||
() =>
|
||||
async () =>
|
||||
{
|
||||
metadata.SortTitle = string.IsNullOrWhiteSpace(metadata.SortTitle)
|
||||
? _fallbackMetadataProvider.GetSortTitle(metadata.Title)
|
||||
: metadata.SortTitle;
|
||||
metadata.ShowId = show.Id;
|
||||
show.ShowMetadata = new List<ShowMetadata> { metadata };
|
||||
});
|
||||
|
||||
await _televisionRepository.Update(show);
|
||||
}
|
||||
return await _metadataRepository.Add(metadata);
|
||||
});
|
||||
|
||||
private async Task<Option<MovieMetadata>> LoadMetadata(Movie mediaItem, string nfoFileName)
|
||||
{
|
||||
@@ -272,10 +337,11 @@ namespace ErsatzTV.Core.Metadata
|
||||
Plot = nfo.Plot,
|
||||
Outline = nfo.Outline,
|
||||
Tagline = nfo.Tagline,
|
||||
Year = nfo.Year,
|
||||
ReleaseDate = GetAired(nfo.Premiered) ?? new DateTime(nfo.Year, 1, 1),
|
||||
Year = GetYear(nfo.Year, nfo.Premiered),
|
||||
ReleaseDate = GetAired(nfo.Year, nfo.Premiered),
|
||||
Genres = nfo.Genres.Map(g => new Genre { Name = g }).ToList(),
|
||||
Tags = nfo.Tags.Map(t => new Tag { Name = t }).ToList()
|
||||
Tags = nfo.Tags.Map(t => new Tag { Name = t }).ToList(),
|
||||
Studios = nfo.Studios.Map(s => new Studio { Name = s }).ToList()
|
||||
},
|
||||
None);
|
||||
}
|
||||
@@ -300,7 +366,7 @@ namespace ErsatzTV.Core.Metadata
|
||||
MetadataKind = MetadataKind.Sidecar,
|
||||
DateUpdated = File.GetLastWriteTimeUtc(nfoFileName),
|
||||
Title = nfo.Title,
|
||||
ReleaseDate = GetAired(nfo.Aired),
|
||||
ReleaseDate = GetAired(0, nfo.Aired),
|
||||
Plot = nfo.Plot
|
||||
};
|
||||
return Tuple(metadata, nfo.Episode);
|
||||
@@ -332,7 +398,8 @@ namespace ErsatzTV.Core.Metadata
|
||||
Outline = nfo.Outline,
|
||||
Tagline = nfo.Tagline,
|
||||
Genres = nfo.Genres.Map(g => new Genre { Name = g }).ToList(),
|
||||
Tags = nfo.Tags.Map(t => new Tag { Name = t }).ToList()
|
||||
Tags = nfo.Tags.Map(t => new Tag { Name = t }).ToList(),
|
||||
Studios = nfo.Studios.Map(s => new Studio { Name = s }).ToList()
|
||||
},
|
||||
None);
|
||||
}
|
||||
@@ -343,21 +410,38 @@ namespace ErsatzTV.Core.Metadata
|
||||
}
|
||||
}
|
||||
|
||||
private static DateTime? GetAired(string aired)
|
||||
private static int? GetYear(int year, string premiered)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(aired))
|
||||
if (year > 1000)
|
||||
{
|
||||
return year;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(premiered))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (DateTime.TryParse(aired, out DateTime parsed))
|
||||
if (DateTime.TryParse(premiered, out DateTime parsed))
|
||||
{
|
||||
return parsed;
|
||||
return parsed.Year;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static DateTime? GetAired(int year, string aired)
|
||||
{
|
||||
DateTime? fallback = year > 1000 ? new DateTime(year, 1, 1) : null;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(aired))
|
||||
{
|
||||
return fallback;
|
||||
}
|
||||
|
||||
return DateTime.TryParse(aired, out DateTime parsed) ? parsed : fallback;
|
||||
}
|
||||
|
||||
[XmlRoot("movie")]
|
||||
public class MovieNfo
|
||||
{
|
||||
@@ -387,6 +471,9 @@ namespace ErsatzTV.Core.Metadata
|
||||
|
||||
[XmlElement("tag")]
|
||||
public List<string> Tags { get; set; }
|
||||
|
||||
[XmlElement("studio")]
|
||||
public List<string> Studios { get; set; }
|
||||
}
|
||||
|
||||
[XmlRoot("tvshow")]
|
||||
@@ -415,6 +502,9 @@ namespace ErsatzTV.Core.Metadata
|
||||
|
||||
[XmlElement("tag")]
|
||||
public List<string> Tags { get; set; }
|
||||
|
||||
[XmlElement("studio")]
|
||||
public List<string> Studios { get; set; }
|
||||
}
|
||||
|
||||
[XmlRoot("episodedetails")]
|
||||
|
||||
@@ -17,19 +17,19 @@ namespace ErsatzTV.Core.Metadata
|
||||
{
|
||||
private readonly ILocalFileSystem _localFileSystem;
|
||||
private readonly ILogger<LocalStatisticsProvider> _logger;
|
||||
private readonly IMediaItemRepository _mediaItemRepository;
|
||||
private readonly IMetadataRepository _metadataRepository;
|
||||
|
||||
public LocalStatisticsProvider(
|
||||
IMediaItemRepository mediaItemRepository,
|
||||
IMetadataRepository metadataRepository,
|
||||
ILocalFileSystem localFileSystem,
|
||||
ILogger<LocalStatisticsProvider> logger)
|
||||
{
|
||||
_mediaItemRepository = mediaItemRepository;
|
||||
_metadataRepository = metadataRepository;
|
||||
_localFileSystem = localFileSystem;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<bool> RefreshStatistics(string ffprobePath, MediaItem mediaItem)
|
||||
public async Task<Either<BaseError, bool>> RefreshStatistics(string ffprobePath, MediaItem mediaItem)
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -40,14 +40,20 @@ namespace ErsatzTV.Core.Metadata
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(mediaItem))
|
||||
};
|
||||
|
||||
FFprobe ffprobe = await GetProbeOutput(ffprobePath, filePath);
|
||||
MediaVersion version = ProjectToMediaVersion(ffprobe);
|
||||
return await ApplyVersionUpdate(mediaItem, version, filePath);
|
||||
Either<BaseError, FFprobe> maybeProbe = await GetProbeOutput(ffprobePath, filePath);
|
||||
return await maybeProbe.Match(
|
||||
async ffprobe =>
|
||||
{
|
||||
MediaVersion version = ProjectToMediaVersion(ffprobe);
|
||||
bool result = await ApplyVersionUpdate(mediaItem, version, filePath);
|
||||
return Right<BaseError, bool>(result);
|
||||
},
|
||||
error => Task.FromResult(Left<BaseError, bool>(error)));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to refresh statistics for media item {Id}", mediaItem.Id);
|
||||
return false;
|
||||
return BaseError.New(ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,10 +79,10 @@ namespace ErsatzTV.Core.Metadata
|
||||
mediaItemVersion.VideoProfile = version.VideoProfile;
|
||||
mediaItemVersion.VideoScanKind = version.VideoScanKind;
|
||||
|
||||
return await _mediaItemRepository.Update(mediaItem) && durationChange;
|
||||
return await _metadataRepository.UpdateLocalStatistics(mediaItemVersion) && durationChange;
|
||||
}
|
||||
|
||||
private Task<FFprobe> GetProbeOutput(string ffprobePath, string filePath)
|
||||
private Task<Either<BaseError, FFprobe>> GetProbeOutput(string ffprobePath, string filePath)
|
||||
{
|
||||
var startInfo = new ProcessStartInfo
|
||||
{
|
||||
@@ -101,11 +107,13 @@ namespace ErsatzTV.Core.Metadata
|
||||
};
|
||||
|
||||
probe.Start();
|
||||
return probe.StandardOutput.ReadToEndAsync().MapAsync(
|
||||
return probe.StandardOutput.ReadToEndAsync().MapAsync<string, Either<BaseError, FFprobe>>(
|
||||
async output =>
|
||||
{
|
||||
await probe.WaitForExitAsync();
|
||||
return JsonConvert.DeserializeObject<FFprobe>(output);
|
||||
return probe.ExitCode == 0
|
||||
? JsonConvert.DeserializeObject<FFprobe>(output)
|
||||
: BaseError.New($"FFprobe at {ffprobePath} exited with code {probe.ExitCode}");
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
|
||||
namespace ErsatzTV.Core.Metadata
|
||||
{
|
||||
public class MediaItemScanResult<T> where T : MediaItem
|
||||
{
|
||||
public MediaItemScanResult(T item) => Item = item;
|
||||
|
||||
public T Item { get; }
|
||||
|
||||
public bool IsAdded { get; set; }
|
||||
public bool IsUpdated { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Core.Interfaces.Images;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using LanguageExt;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using static LanguageExt.Prelude;
|
||||
@@ -21,19 +22,23 @@ namespace ErsatzTV.Core.Metadata
|
||||
private readonly ILocalMetadataProvider _localMetadataProvider;
|
||||
private readonly ILogger<MovieFolderScanner> _logger;
|
||||
private readonly IMovieRepository _movieRepository;
|
||||
private readonly ISearchIndex _searchIndex;
|
||||
|
||||
public MovieFolderScanner(
|
||||
ILocalFileSystem localFileSystem,
|
||||
IMovieRepository movieRepository,
|
||||
ILocalStatisticsProvider localStatisticsProvider,
|
||||
ILocalMetadataProvider localMetadataProvider,
|
||||
IMetadataRepository metadataRepository,
|
||||
IImageCache imageCache,
|
||||
ISearchIndex searchIndex,
|
||||
ILogger<MovieFolderScanner> logger)
|
||||
: base(localFileSystem, localStatisticsProvider, imageCache, logger)
|
||||
: base(localFileSystem, localStatisticsProvider, metadataRepository, imageCache, logger)
|
||||
{
|
||||
_localFileSystem = localFileSystem;
|
||||
_movieRepository = movieRepository;
|
||||
_localMetadataProvider = localMetadataProvider;
|
||||
_searchIndex = searchIndex;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
@@ -71,20 +76,33 @@ namespace ErsatzTV.Core.Metadata
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
foreach (string file in allFiles.OrderBy(identity))
|
||||
{
|
||||
// TODO: optimize dbcontext use here, do we need tracking? can we make partial updates with dapper?
|
||||
// TODO: figure out how to rebuild playlists
|
||||
Either<BaseError, Movie> maybeMovie = await _movieRepository
|
||||
Either<BaseError, MediaItemScanResult<Movie>> maybeMovie = await _movieRepository
|
||||
.GetOrAdd(libraryPath, file)
|
||||
.BindT(movie => UpdateStatistics(movie, ffprobePath).MapT(_ => movie))
|
||||
.BindT(movie => UpdateStatistics(movie, ffprobePath))
|
||||
.BindT(UpdateMetadata)
|
||||
.BindT(movie => UpdateArtwork(movie, ArtworkKind.Poster))
|
||||
.BindT(movie => UpdateArtwork(movie, ArtworkKind.FanArt));
|
||||
|
||||
maybeMovie.IfLeft(
|
||||
error => _logger.LogWarning("Error processing movie at {Path}: {Error}", file, error.Value));
|
||||
await maybeMovie.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 });
|
||||
}
|
||||
},
|
||||
error =>
|
||||
{
|
||||
_logger.LogWarning("Error processing movie at {Path}: {Error}", file, error.Value);
|
||||
return Task.CompletedTask;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,17 +111,20 @@ namespace ErsatzTV.Core.Metadata
|
||||
if (!_localFileSystem.FileExists(path))
|
||||
{
|
||||
_logger.LogInformation("Removing missing movie at {Path}", path);
|
||||
await _movieRepository.DeleteByPath(libraryPath, path);
|
||||
List<int> ids = await _movieRepository.DeleteByPath(libraryPath, path);
|
||||
await _searchIndex.RemoveItems(ids);
|
||||
}
|
||||
}
|
||||
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
private async Task<Either<BaseError, Movie>> UpdateMetadata(Movie movie)
|
||||
private async Task<Either<BaseError, MediaItemScanResult<Movie>>> UpdateMetadata(
|
||||
MediaItemScanResult<Movie> result)
|
||||
{
|
||||
try
|
||||
{
|
||||
Movie movie = result.Item;
|
||||
await LocateNfoFile(movie).Match(
|
||||
async nfoFile =>
|
||||
{
|
||||
@@ -115,7 +136,10 @@ namespace ErsatzTV.Core.Metadata
|
||||
if (shouldUpdate)
|
||||
{
|
||||
_logger.LogDebug("Refreshing {Attribute} from {Path}", "Sidecar Metadata", nfoFile);
|
||||
await _localMetadataProvider.RefreshSidecarMetadata(movie, nfoFile);
|
||||
if (await _localMetadataProvider.RefreshSidecarMetadata(movie, nfoFile))
|
||||
{
|
||||
result.IsUpdated = true;
|
||||
}
|
||||
}
|
||||
},
|
||||
async () =>
|
||||
@@ -124,37 +148,40 @@ namespace ErsatzTV.Core.Metadata
|
||||
{
|
||||
string path = movie.MediaVersions.Head().MediaFiles.Head().Path;
|
||||
_logger.LogDebug("Refreshing {Attribute} for {Path}", "Fallback Metadata", path);
|
||||
await _localMetadataProvider.RefreshFallbackMetadata(movie);
|
||||
if (await _localMetadataProvider.RefreshFallbackMetadata(movie))
|
||||
{
|
||||
result.IsUpdated = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return movie;
|
||||
return result;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return BaseError.New(ex.Message);
|
||||
return BaseError.New(ex.ToString());
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<Either<BaseError, Movie>> UpdateArtwork(Movie movie, ArtworkKind artworkKind)
|
||||
private async Task<Either<BaseError, MediaItemScanResult<Movie>>> UpdateArtwork(
|
||||
MediaItemScanResult<Movie> result,
|
||||
ArtworkKind artworkKind)
|
||||
{
|
||||
try
|
||||
{
|
||||
Movie movie = result.Item;
|
||||
await LocateArtwork(movie, artworkKind).IfSomeAsync(
|
||||
async posterFile =>
|
||||
{
|
||||
MovieMetadata metadata = movie.MovieMetadata.Head();
|
||||
if (RefreshArtwork(posterFile, metadata, artworkKind))
|
||||
{
|
||||
await _movieRepository.Update(movie);
|
||||
}
|
||||
await RefreshArtwork(posterFile, metadata, artworkKind);
|
||||
});
|
||||
|
||||
return movie;
|
||||
return result;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return BaseError.New(ex.Message);
|
||||
return BaseError.New(ex.ToString());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Core.Interfaces.Images;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using LanguageExt;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using static LanguageExt.Prelude;
|
||||
@@ -19,6 +20,7 @@ namespace ErsatzTV.Core.Metadata
|
||||
private readonly ILocalFileSystem _localFileSystem;
|
||||
private readonly ILocalMetadataProvider _localMetadataProvider;
|
||||
private readonly ILogger<TelevisionFolderScanner> _logger;
|
||||
private readonly ISearchIndex _searchIndex;
|
||||
private readonly ITelevisionRepository _televisionRepository;
|
||||
|
||||
public TelevisionFolderScanner(
|
||||
@@ -26,16 +28,20 @@ namespace ErsatzTV.Core.Metadata
|
||||
ITelevisionRepository televisionRepository,
|
||||
ILocalStatisticsProvider localStatisticsProvider,
|
||||
ILocalMetadataProvider localMetadataProvider,
|
||||
IMetadataRepository metadataRepository,
|
||||
IImageCache imageCache,
|
||||
ISearchIndex searchIndex,
|
||||
ILogger<TelevisionFolderScanner> logger) : base(
|
||||
localFileSystem,
|
||||
localStatisticsProvider,
|
||||
metadataRepository,
|
||||
imageCache,
|
||||
logger)
|
||||
{
|
||||
_localFileSystem = localFileSystem;
|
||||
_televisionRepository = televisionRepository;
|
||||
_localMetadataProvider = localMetadataProvider;
|
||||
_searchIndex = searchIndex;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
@@ -53,14 +59,26 @@ namespace ErsatzTV.Core.Metadata
|
||||
|
||||
foreach (string showFolder in allShowFolders)
|
||||
{
|
||||
Either<BaseError, Show> maybeShow =
|
||||
Either<BaseError, MediaItemScanResult<Show>> maybeShow =
|
||||
await FindOrCreateShow(libraryPath.Id, showFolder)
|
||||
.BindT(show => UpdateMetadataForShow(show, showFolder))
|
||||
.BindT(show => UpdateArtworkForShow(show, showFolder, ArtworkKind.Poster))
|
||||
.BindT(show => UpdateArtworkForShow(show, showFolder, ArtworkKind.FanArt));
|
||||
|
||||
await maybeShow.Match(
|
||||
show => ScanSeasons(libraryPath, ffprobePath, show, showFolder),
|
||||
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, result.Item, showFolder);
|
||||
},
|
||||
_ => Task.FromResult(Unit.Default));
|
||||
}
|
||||
|
||||
@@ -74,19 +92,20 @@ namespace ErsatzTV.Core.Metadata
|
||||
}
|
||||
|
||||
await _televisionRepository.DeleteEmptySeasons(libraryPath);
|
||||
await _televisionRepository.DeleteEmptyShows(libraryPath);
|
||||
List<int> ids = await _televisionRepository.DeleteEmptyShows(libraryPath);
|
||||
await _searchIndex.RemoveItems(ids);
|
||||
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
private async Task<Either<BaseError, Show>> FindOrCreateShow(
|
||||
private async Task<Either<BaseError, MediaItemScanResult<Show>>> FindOrCreateShow(
|
||||
int libraryPathId,
|
||||
string showFolder)
|
||||
{
|
||||
ShowMetadata metadata = await _localMetadataProvider.GetMetadataForShow(showFolder);
|
||||
Option<Show> maybeShow = await _televisionRepository.GetShowByMetadata(libraryPathId, metadata);
|
||||
return await maybeShow.Match(
|
||||
show => Right<BaseError, Show>(show).AsTask(),
|
||||
show => Right<BaseError, MediaItemScanResult<Show>>(new MediaItemScanResult<Show>(show)).AsTask(),
|
||||
async () => await _televisionRepository.AddShow(libraryPathId, showFolder, metadata));
|
||||
}
|
||||
|
||||
@@ -128,7 +147,9 @@ namespace ErsatzTV.Core.Metadata
|
||||
// TODO: figure out how to rebuild playlists
|
||||
Either<BaseError, Episode> maybeEpisode = await _televisionRepository
|
||||
.GetOrAddEpisode(season, libraryPath, file)
|
||||
.BindT(episode => UpdateStatistics(episode, ffprobePath).MapT(_ => episode))
|
||||
.BindT(
|
||||
episode => UpdateStatistics(new MediaItemScanResult<Episode>(episode), ffprobePath)
|
||||
.MapT(_ => episode))
|
||||
.BindT(UpdateMetadata)
|
||||
.BindT(UpdateThumbnail);
|
||||
|
||||
@@ -139,12 +160,13 @@ namespace ErsatzTV.Core.Metadata
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
private async Task<Either<BaseError, Show>> UpdateMetadataForShow(
|
||||
Show show,
|
||||
private async Task<Either<BaseError, MediaItemScanResult<Show>>> UpdateMetadataForShow(
|
||||
MediaItemScanResult<Show> result,
|
||||
string showFolder)
|
||||
{
|
||||
try
|
||||
{
|
||||
Show show = result.Item;
|
||||
await LocateNfoFileForShow(showFolder).Match(
|
||||
async nfoFile =>
|
||||
{
|
||||
@@ -156,7 +178,10 @@ namespace ErsatzTV.Core.Metadata
|
||||
if (shouldUpdate)
|
||||
{
|
||||
_logger.LogDebug("Refreshing {Attribute} from {Path}", "Sidecar Metadata", nfoFile);
|
||||
await _localMetadataProvider.RefreshSidecarMetadata(show, nfoFile);
|
||||
if (await _localMetadataProvider.RefreshSidecarMetadata(show, nfoFile))
|
||||
{
|
||||
result.IsUpdated = true;
|
||||
}
|
||||
}
|
||||
},
|
||||
async () =>
|
||||
@@ -164,15 +189,18 @@ namespace ErsatzTV.Core.Metadata
|
||||
if (!Optional(show.ShowMetadata).Flatten().Any())
|
||||
{
|
||||
_logger.LogDebug("Refreshing {Attribute} for {Path}", "Fallback Metadata", showFolder);
|
||||
await _localMetadataProvider.RefreshFallbackMetadata(show, showFolder);
|
||||
if (await _localMetadataProvider.RefreshFallbackMetadata(show, showFolder))
|
||||
{
|
||||
result.IsUpdated = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return show;
|
||||
return result;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return BaseError.New(ex.Message);
|
||||
return BaseError.New(ex.ToString());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -197,7 +225,11 @@ namespace ErsatzTV.Core.Metadata
|
||||
},
|
||||
async () =>
|
||||
{
|
||||
if (!Optional(episode.EpisodeMetadata).Flatten().Any())
|
||||
bool shouldUpdate = Optional(episode.EpisodeMetadata).Flatten().HeadOrNone().Match(
|
||||
m => m.DateUpdated == DateTime.MinValue,
|
||||
true);
|
||||
|
||||
if (shouldUpdate)
|
||||
{
|
||||
string path = episode.MediaVersions.Head().MediaFiles.Head().Path;
|
||||
_logger.LogDebug("Refreshing {Attribute} for {Path}", "Fallback Metadata", path);
|
||||
@@ -209,32 +241,30 @@ namespace ErsatzTV.Core.Metadata
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return BaseError.New(ex.Message);
|
||||
return BaseError.New(ex.ToString());
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<Either<BaseError, Show>> UpdateArtworkForShow(
|
||||
Show show,
|
||||
private async Task<Either<BaseError, MediaItemScanResult<Show>>> UpdateArtworkForShow(
|
||||
MediaItemScanResult<Show> result,
|
||||
string showFolder,
|
||||
ArtworkKind artworkKind)
|
||||
{
|
||||
try
|
||||
{
|
||||
Show show = result.Item;
|
||||
await LocateArtworkForShow(showFolder, artworkKind).IfSomeAsync(
|
||||
async posterFile =>
|
||||
{
|
||||
ShowMetadata metadata = show.ShowMetadata.Head();
|
||||
if (RefreshArtwork(posterFile, metadata, artworkKind))
|
||||
{
|
||||
await _televisionRepository.Update(show);
|
||||
}
|
||||
await RefreshArtwork(posterFile, metadata, artworkKind);
|
||||
});
|
||||
|
||||
return show;
|
||||
return result;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return BaseError.New(ex.Message);
|
||||
return BaseError.New(ex.ToString());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -245,25 +275,15 @@ namespace ErsatzTV.Core.Metadata
|
||||
await LocatePoster(season, seasonFolder).IfSomeAsync(
|
||||
async posterFile =>
|
||||
{
|
||||
season.SeasonMetadata ??= new List<SeasonMetadata>();
|
||||
if (!season.SeasonMetadata.Any())
|
||||
{
|
||||
season.SeasonMetadata.Add(new SeasonMetadata { SeasonId = season.Id });
|
||||
}
|
||||
|
||||
SeasonMetadata metadata = season.SeasonMetadata.Head();
|
||||
|
||||
if (RefreshArtwork(posterFile, metadata, ArtworkKind.Poster))
|
||||
{
|
||||
await _televisionRepository.Update(season);
|
||||
}
|
||||
await RefreshArtwork(posterFile, metadata, ArtworkKind.Poster);
|
||||
});
|
||||
|
||||
return season;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return BaseError.New(ex.Message);
|
||||
return BaseError.New(ex.ToString());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -275,17 +295,14 @@ namespace ErsatzTV.Core.Metadata
|
||||
async posterFile =>
|
||||
{
|
||||
EpisodeMetadata metadata = episode.EpisodeMetadata.Head();
|
||||
if (RefreshArtwork(posterFile, metadata, ArtworkKind.Thumbnail))
|
||||
{
|
||||
await _televisionRepository.Update(episode);
|
||||
}
|
||||
await RefreshArtwork(posterFile, metadata, ArtworkKind.Thumbnail);
|
||||
});
|
||||
|
||||
return episode;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return BaseError.New(ex.Message);
|
||||
return BaseError.New(ex.ToString());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user