Compare commits

...
Author SHA1 Message Date
Jason DoveandGitHub c5c28cb92d fix playback for media containing attached pictures (#83) 2021-03-18 01:49:30 +00:00
Jason DoveandGitHub 636bf0715b bug fixes (#82)
* fix crash rebuilding playlists from ui

* fix error creating first channel
2021-03-18 01:27:08 +00:00
Jason Dove 0ca15ee7a8 fix docker release [no ci] 2021-03-17 16:46:35 -05:00
Jason Dove 6565240eeb try ci with isolated builders 2021-03-17 16:31:16 -05:00
Jason Dove d64188927c try ci without docker cache 2021-03-17 16:11:44 -05:00
Jason Dove 0ecec3cb07 include hidden plex libraries 2021-03-16 20:40:50 -05:00
Jason DoveandGitHub a8e861abc0 add optional ffmpeg reports (#81)
* log full exceptions in plex tv api client

* add optional ffmpeg reports
2021-03-17 01:22:09 +00:00
Jason DoveandGitHub 76446e0d69 prevent repeated playout items when reshuffling (#80) 2021-03-15 11:28:07 +00:00
Jason Dove c6d90ad750 allow plex re-authentication 2021-03-14 21:05:14 -05:00
Jason Dove e5a9ef6196 add episode posters to xmltv 2021-03-14 18:49:30 -05:00
Jason Dove 8439d6fd54 fix channel logos in xmltv 2021-03-14 18:44:19 -05:00
Jason DoveandGitHub 1773691c39 create collection from add to collection dialog (#79) 2021-03-14 20:50:23 +00:00
Jason Dove 940cdd10a3 update all references 2021-03-14 15:29:14 -05:00
Jason Dove 6beb9f7e33 regularly scan plex media sources 2021-03-14 15:21:23 -05:00
Jason DoveandGitHub 898a21dcd9 clean up tables (#78)
* add plex library sorting options

* add playout sorting options
2021-03-14 20:13:28 +00:00
Jason DoveandGitHub a01888792a delet items removed from plex (#77)
* delete items removed from plex

* fix tests
2021-03-14 17:54:32 +00:00
Jason DoveandGitHub 8b1f8dd36b support plex media with missing release date (#76) 2021-03-14 17:32:49 +00:00
Jason DoveandGitHub e9b26d6bdb fix plex async genre sync (#75) 2021-03-14 16:27:06 +00:00
Jason DoveandGitHub 79b2e9dbfe fix plex movie scanning performance (#74) 2021-03-14 16:25:05 +00:00
Jason DoveandGitHub 9ba0cbd84f enable plex for television (#73)
* add plex show, season sync

* sync plex episodes

* sync plex episode statistics

* update plex artwork as needed

* code cleanup

* add note about tests
2021-03-14 16:03:04 +00:00
Jason Dove d5b48d2601 fix plex movies with no genres 2021-03-13 15:28:45 -06:00
Jason DoveandGitHub aa938baec8 enable plex for movies (#72)
* re-enable plex, temp force secure connections

* add plex fanart

* synchronize genre from plex

* fix plex library sync

* improve stream error handling

* synchronize plex artwork

* use switch instead of button

* prioritize local connections for insecure plex sources

* sign out of plex

* better plex sign in/out

* code cleanup

* fix plex movie aspect ratio and scan type
2021-03-13 21:04:54 +00:00
Jason DoveandGitHub a13f964200 add movie poster to xmltv (#71) 2021-03-13 11:55:15 +00:00
Jason DoveandGitHub 0da9701f9c include movie date in xmltv (#70) 2021-03-13 03:20:39 +00:00
Jason Dove b3f4c22f49 update docker cache [no ci] 2021-03-12 21:13:29 -06:00
Jason DoveandGitHub 50fafbfb98 remove duplicate subtitle tag from xmltv (#69) 2021-03-13 03:03:59 +00:00
Jason DoveandGitHub 914d128610 set title, subtitle, category in xmltv (#68) 2021-03-13 02:47:34 +00:00
Jason DoveandGitHub 1a2f36f561 fix loading seasons with empty episode plot (#67) 2021-03-13 02:15:35 +00:00
Jason DoveandGitHub 96887fbd79 properly set sort title on new tv shows (#66) 2021-03-13 00:51:08 +00:00
Jason DoveandGitHub c07e2afff4 fix playouts that use shows or seasons (#65) 2021-03-13 00:50:17 +00:00
Jason DoveandGitHub 4953617f79 custom collection playback order (#64)
* add custom index to collection items

* add custom collection order to ui

* cleanup
2021-03-12 19:24:28 +00:00
Jason DoveandGitHub 1587ac7d62 ffmpeg and ffprobe validation fixes (#63)
* abort building playout if any collection contains a zero-duration item

* surface errors calling ffprobe

* improve ffmpeg/ffprobe path validation
2021-03-12 02:20:18 +00:00
Jason DoveandGitHub c240169fc9 add multiselect and movie tags (#62)
* add basic selection behavior to search results

* add search scrolling, selection actions

* include shows in multiselect

* multiselect movies, shows, collection items

* add movie and show tags

* code cleanup

* update show screenshot
2021-03-11 18:49:00 +00:00
Jason DoveandGitHub 76d6725dd5 add movie and show genres (#61)
* load movie genres from sidecar metadata

* search movie and tv show genres

* rebuild all playouts (needed after time zone fix)

* code cleanup

* fix duplicate tv show search results
2021-03-11 02:42:04 +00:00
Jason DoveandGitHub c016cac8d4 fix playout time zone bugs (#60)
* fix time zone bugs with playout building

* more time zone fixes
2021-03-10 22:37:57 +00:00
Jason DoveandGitHub e624627ae1 fix editing channel number (#59) 2021-03-10 19:15:08 +00:00
Jason DoveandGitHub 46bcf03d9a regenerate sort titles for all media items (#58) 2021-03-10 18:06:27 +00:00
Jason DoveandGitHub ab9a8493d9 fix tv show sorting (#57) 2021-03-10 14:14:33 +00:00
Jason DoveandGitHub b1ecbafb6e tv ui update (#56)
* update tv show ui

* update tv season ui

* list episode details on tv season page

* remove episode page

* remove breadcrumbs

* move home link

* code cleanup

* update screenshots
2021-03-10 12:24:18 +00:00
163 changed files with 18813 additions and 830 deletions
+12 -14
View File
@@ -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
+12 -14
View File
@@ -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
@@ -74,7 +74,7 @@ namespace ErsatzTV.Application.Channels.Commands
_ => BaseError.New("Channel number must be unique"),
() =>
{
if (Regex.IsMatch(createChannel.Number, @"^[0-9]+(\.[0-9])?$"))
if (Regex.IsMatch(createChannel.Number, Channel.NumberValidator))
{
return createChannel.Number;
}
@@ -82,7 +82,7 @@ namespace ErsatzTV.Application.Channels.Commands
int matchId = match.Map(c => c.Id).IfNone(updateChannel.ChannelId);
if (matchId == updateChannel.ChannelId)
{
if (Regex.IsMatch(updateChannel.Number, @"^[0-9](\.[0-9])?$"))
if (Regex.IsMatch(updateChannel.Number, Channel.NumberValidator))
{
return updateChannel.Number;
}
@@ -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)
};
}
}
@@ -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; }
}
}
+22 -8
View File
@@ -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,17 @@ 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,
$"Episode {episodeMetadata.Episode.EpisodeNumber}",
episodeMetadata.Episode.EpisodeNumber.ToString(),
GetThumbnail(episodeMetadata),
episodeMetadata.Episode.EpisodeNumber.ToString());
episodeMetadata.Episode.EpisodeMetadata.HeadOrNone().Match(
em => em.Plot ?? string.Empty,
() => string.Empty),
GetThumbnail(episodeMetadata));
internal static MovieCardViewModel ProjectToViewModel(MovieMetadata movieMetadata) =>
new(
@@ -51,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);
}
@@ -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; }
}
}
@@ -18,7 +18,25 @@ namespace ErsatzTV.Application.MediaCards.Queries
public Task<Either<BaseError, SearchCardResultsViewModel>> Handle(
GetSearchCards request,
CancellationToken cancellationToken) =>
Try(_searchRepository.SearchMediaItems(request.Query)).Sequence()
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}")));
}
@@ -7,14 +7,16 @@ namespace ErsatzTV.Application.MediaCards
int EpisodeId,
DateTime Aired,
string ShowTitle,
int ShowId,
int SeasonId,
int Episode,
string Title,
string Subtitle,
string SortTitle,
string Poster,
string Placeholder) : MediaCardViewModel(
string Plot,
string Poster) : MediaCardViewModel(
EpisodeId,
Title,
Subtitle,
SortTitle,
$"Episode {Episode}",
$"Episode {Episode}",
Poster)
{
}
@@ -10,6 +10,7 @@
string SortTitle,
string Poster,
string Placeholder) : MediaCardViewModel(
TelevisionSeasonId,
Title,
Subtitle,
SortTitle,
@@ -2,6 +2,7 @@
{
public record TelevisionShowCardViewModel
(int TelevisionShowId, string Title, string Subtitle, string SortTitle, string Poster) : MediaCardViewModel(
TelevisionShowId,
Title,
Subtitle,
SortTitle,
@@ -0,0 +1,9 @@
using System.Collections.Generic;
using ErsatzTV.Core;
using LanguageExt;
namespace ErsatzTV.Application.MediaCollections.Commands
{
public record AddItemsToCollection
(int CollectionId, List<int> MovieIds, List<int> ShowIds) : MediatR.IRequest<Either<BaseError, Unit>>;
}
@@ -0,0 +1,80 @@
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.Interfaces.Repositories;
using LanguageExt;
using static LanguageExt.Prelude;
namespace ErsatzTV.Application.MediaCollections.Commands
{
public class
AddItemsToCollectionHandler : MediatR.IRequestHandler<AddItemsToCollection, Either<BaseError, Unit>>
{
private readonly ChannelWriter<IBackgroundServiceRequest> _channel;
private readonly IMediaCollectionRepository _mediaCollectionRepository;
private readonly IMovieRepository _movieRepository;
private readonly ITelevisionRepository _televisionRepository;
public AddItemsToCollectionHandler(
IMediaCollectionRepository mediaCollectionRepository,
IMovieRepository movieRepository,
ITelevisionRepository televisionRepository,
ChannelWriter<IBackgroundServiceRequest> channel)
{
_mediaCollectionRepository = mediaCollectionRepository;
_movieRepository = movieRepository;
_televisionRepository = televisionRepository;
_channel = channel;
}
public Task<Either<BaseError, Unit>> Handle(
AddItemsToCollection request,
CancellationToken cancellationToken) =>
Validate(request)
.MapT(_ => ApplyAddItemsRequest(request))
.Bind(v => v.ToEitherAsync());
private async Task<Unit> ApplyAddItemsRequest(AddItemsToCollection request)
{
if (await _mediaCollectionRepository.AddMediaItems(
request.CollectionId,
request.MovieIds.Append(request.ShowIds).ToList()))
{
// rebuild all playouts that use this collection
foreach (int playoutId in await _mediaCollectionRepository
.PlayoutIdsUsingCollection(request.CollectionId))
{
await _channel.WriteAsync(new BuildPlayout(playoutId, true));
}
}
return Unit.Default;
}
private async Task<Validation<BaseError, Unit>> Validate(AddItemsToCollection request) =>
(await CollectionMustExist(request), await ValidateMovies(request), await ValidateShows(request))
.Apply((_, _, _) => Unit.Default);
private Task<Validation<BaseError, Unit>> CollectionMustExist(AddItemsToCollection request) =>
_mediaCollectionRepository.GetCollectionWithItems(request.CollectionId)
.MapT(_ => Unit.Default)
.Map(v => v.ToValidation<BaseError>("Collection does not exist."));
private Task<Validation<BaseError, Unit>> ValidateMovies(AddItemsToCollection request) =>
_movieRepository.AllMoviesExist(request.MovieIds)
.Map(Optional)
.Filter(v => v == true)
.MapT(_ => Unit.Default)
.Map(v => v.ToValidation<BaseError>("Movie does not exist"));
private Task<Validation<BaseError, Unit>> ValidateShows(AddItemsToCollection request) =>
_televisionRepository.AllShowsExist(request.ShowIds)
.Map(Optional)
.Filter(v => v == true)
.MapT(_ => Unit.Default)
.Map(v => v.ToValidation<BaseError>("Show does not exist"));
}
}
@@ -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,
+3 -1
View File
@@ -14,7 +14,9 @@ namespace ErsatzTV.Application.Movies
metadata.Year?.ToString(),
metadata.Plot,
Artwork(metadata, ArtworkKind.Poster),
Artwork(metadata, ArtworkKind.FanArt));
Artwork(metadata, ArtworkKind.FanArt),
metadata.Genres.Map(g => g.Name).ToList(),
metadata.Tags.Map(t => t.Name).ToList());
}
private static string Artwork(Metadata metadata, ArtworkKind artworkKind) =>
+11 -2
View File
@@ -1,4 +1,13 @@
namespace ErsatzTV.Application.Movies
using System.Collections.Generic;
namespace ErsatzTV.Application.Movies
{
public record MovieViewModel(string Title, string Year, string Plot, string Poster, string FanArt);
public record MovieViewModel(
string Title,
string Year,
string Plot,
string Poster,
string FanArt,
List<string> Genres,
List<string> Tags);
}
@@ -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,36 @@
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 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;
public SignOutOfPlexHandler(
IMediaSourceRepository mediaSourceRepository,
IPlexSecretStore plexSecretStore,
IEntityLocker entityLocker)
{
_mediaSourceRepository = mediaSourceRepository;
_plexSecretStore = plexSecretStore;
_entityLocker = entityLocker;
}
public async Task<Either<BaseError, Unit>> Handle(SignOutOfPlex request, CancellationToken cancellationToken)
{
await _mediaSourceRepository.DeleteAllPlex();
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;
}
@@ -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;
}
@@ -6,10 +6,13 @@ 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
{
@@ -17,8 +20,10 @@ namespace ErsatzTV.Application.Streaming.Queries
GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<GetPlayoutItemProcessByChannelNumber>
{
private readonly FFmpegProcessService _ffmpegProcessService;
private readonly ILocalFileSystem _localFileSystem;
private readonly ILogger<GetPlayoutItemProcessByChannelNumberHandler> _logger;
private readonly IMediaSourceRepository _mediaSourceRepository;
private readonly IConfigElementRepository _configElementRepository;
private readonly IPlayoutRepository _playoutRepository;
public GetPlayoutItemProcessByChannelNumberHandler(
@@ -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);
}
}
+15 -8
View File
@@ -1,4 +1,5 @@
using System.Linq;
using System.Collections.Generic;
using System.Linq;
using ErsatzTV.Core.Domain;
using static LanguageExt.Prelude;
@@ -12,7 +13,10 @@ namespace ErsatzTV.Application.Television
show.ShowMetadata.HeadOrNone().Map(m => m.Title ?? string.Empty).IfNone(string.Empty),
show.ShowMetadata.HeadOrNone().Map(m => m.Year?.ToString() ?? string.Empty).IfNone(string.Empty),
show.ShowMetadata.HeadOrNone().Map(m => m.Plot ?? string.Empty).IfNone(string.Empty),
show.ShowMetadata.HeadOrNone().Map(GetPoster).IfNone(string.Empty));
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>()));
internal static TelevisionSeasonViewModel ProjectToViewModel(Season season) =>
new(
@@ -21,7 +25,8 @@ namespace ErsatzTV.Application.Television
season.Show.ShowMetadata.HeadOrNone().Map(m => m.Title ?? string.Empty).IfNone(string.Empty),
season.Show.ShowMetadata.HeadOrNone().Map(m => m.Year?.ToString() ?? string.Empty).IfNone(string.Empty),
season.SeasonNumber == 0 ? "Specials" : $"Season {season.SeasonNumber}",
season.SeasonMetadata.HeadOrNone().Map(GetPoster).IfNone(string.Empty));
season.SeasonMetadata.HeadOrNone().Map(GetPoster).IfNone(string.Empty),
season.Show.ShowMetadata.HeadOrNone().Map(GetFanArt).IfNone(string.Empty));
internal static TelevisionEpisodeViewModel ProjectToViewModel(Episode episode) =>
new(
@@ -32,12 +37,14 @@ namespace ErsatzTV.Application.Television
episode.EpisodeMetadata.HeadOrNone().Map(m => m.Plot ?? string.Empty).IfNone(string.Empty),
episode.EpisodeMetadata.HeadOrNone().Map(GetThumbnail).IfNone(string.Empty));
private static string GetPoster(Metadata metadata) =>
Optional(metadata.Artwork.FirstOrDefault(a => a.ArtworkKind == ArtworkKind.Poster))
.Match(a => a.Path, string.Empty);
private static string GetPoster(Metadata metadata) => GetArtwork(metadata, ArtworkKind.Poster);
private static string GetThumbnail(Metadata metadata) =>
Optional(metadata.Artwork.FirstOrDefault(a => a.ArtworkKind == ArtworkKind.Thumbnail))
private static string GetFanArt(Metadata metadata) => GetArtwork(metadata, ArtworkKind.FanArt);
private static string GetThumbnail(Metadata metadata) => GetArtwork(metadata, ArtworkKind.Thumbnail);
private static string GetArtwork(Metadata metadata, ArtworkKind artworkKind) =>
Optional(metadata.Artwork.FirstOrDefault(a => a.ArtworkKind == artworkKind))
.Match(a => a.Path, string.Empty);
}
}
@@ -1,4 +1,11 @@
namespace ErsatzTV.Application.Television
{
public record TelevisionSeasonViewModel(int Id, int ShowId, string Title, string Year, string Plot, string Poster);
public record TelevisionSeasonViewModel(
int Id,
int ShowId,
string Title,
string Year,
string Name,
string Poster,
string FanArt);
}
@@ -1,4 +1,14 @@
namespace ErsatzTV.Application.Television
using System.Collections.Generic;
namespace ErsatzTV.Application.Television
{
public record TelevisionShowViewModel(int Id, string Title, string Year, string Plot, string Poster);
public record TelevisionShowViewModel(
int Id,
string Title,
string Year,
string Plot,
string Poster,
string FanArt,
List<string> Genres,
List<string> Tags);
}
@@ -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,
@@ -14,15 +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();
}
}
@@ -9,6 +9,8 @@ namespace ErsatzTV.Core.Tests.Fakes
{
public class FakeTelevisionRepository : ITelevisionRepository
{
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();
@@ -64,5 +66,25 @@ 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<Either<BaseError, 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<Unit> AddGenre(ShowMetadata metadata, Genre genre) => throw new NotSupportedException();
public Task<Unit> 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();
}
}
@@ -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()
{
+2
View File
@@ -5,6 +5,8 @@ namespace ErsatzTV.Core.Domain
{
public class Channel
{
public static string NumberValidator = @"^[0-9]+(\.[0-9])?$";
public Channel(Guid uniqueId) => UniqueId = uniqueId;
public int Id { get; set; }
public Guid UniqueId { get; init; }
@@ -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; }
}
}
+1
View File
@@ -10,5 +10,6 @@
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");
}
}
@@ -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; }
}
}
+8
View File
@@ -0,0 +1,8 @@
namespace ErsatzTV.Core.Domain
{
public class Genre
{
public int Id { get; set; }
public string Name { get; set; }
}
}
@@ -15,5 +15,7 @@ namespace ErsatzTV.Core.Domain
public DateTime DateAdded { get; set; }
public DateTime DateUpdated { get; set; }
public List<Artwork> Artwork { get; set; }
public List<Genre> Genres { get; set; }
public List<Tag> Tags { get; set; }
}
}
+8
View File
@@ -0,0 +1,8 @@
namespace ErsatzTV.Core.Domain
{
public class Tag
{
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);
+17 -4
View File
@@ -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);
+6 -5
View File
@@ -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)
+2
View File
@@ -19,6 +19,8 @@ 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 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();
}
}
@@ -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, Unit>> 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);
}
}
@@ -9,13 +9,16 @@ namespace ErsatzTV.Core.Interfaces.Repositories
{
Task<Collection> Add(Collection collection);
Task<bool> AddMediaItem(int collectionId, int mediaItemId);
Task<bool> AddMediaItems(int collectionId, List<int> mediaItemIds);
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);
}
}
@@ -22,7 +22,8 @@ namespace ErsatzTV.Core.Interfaces.Repositories
Task Update(LocalMediaSource localMediaSource);
Task Update(PlexMediaSource plexMediaSource);
Task Update(PlexLibrary plexMediaSourceLibrary);
Task Delete(int id);
Task Delete(int mediaSourceId);
Task<Unit> DeleteAllPlex();
Task DisablePlexLibrarySync(List<int> libraryIds);
Task EnablePlexLibrarySync(IEnumerable<int> libraryIds);
}
@@ -0,0 +1,15 @@
using System.Threading.Tasks;
using ErsatzTV.Core.Domain;
using LanguageExt;
namespace ErsatzTV.Core.Interfaces.Repositories
{
public interface IMetadataRepository
{
Task<Unit> RemoveGenre(Genre genre);
Task<Unit> UpdateStatistics(MediaVersion mediaVersion);
Task<Unit> UpdateArtworkPath(Artwork artwork);
Task<Unit> AddArtwork(Domain.Metadata metadata, Artwork artwork);
Task<Unit> RemoveArtwork(Domain.Metadata metadata, ArtworkKind artworkKind);
}
}
@@ -7,6 +7,7 @@ namespace ErsatzTV.Core.Interfaces.Repositories
{
public interface IMovieRepository
{
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);
@@ -15,5 +16,7 @@ namespace ErsatzTV.Core.Interfaces.Repositories
Task<List<MovieMetadata>> GetPagedMovies(int pageNumber, int pageSize);
Task<IEnumerable<string>> FindMoviePaths(LibraryPath libraryPath);
Task<Unit> DeleteByPath(LibraryPath libraryPath, string path);
Task<Unit> AddGenre(MovieMetadata metadata, Genre genre);
Task<Unit> RemoveMissingPlexMovies(PlexLibrary library, List<string> movieKeys);
}
}
@@ -6,6 +6,8 @@ namespace ErsatzTV.Core.Interfaces.Repositories
{
public interface ISearchRepository
{
public Task<List<MediaItem>> SearchMediaItems(string query);
public Task<List<MediaItem>> SearchMediaItemsByTitle(string query);
public Task<List<MediaItem>> SearchMediaItemsByGenre(string genre);
public Task<List<MediaItem>> SearchMediaItemsByTag(string tag);
}
}
@@ -7,6 +7,7 @@ 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);
@@ -31,5 +32,12 @@ namespace ErsatzTV.Core.Interfaces.Repositories
Task<Unit> DeleteByPath(LibraryPath libraryPath, string path);
Task<Unit> DeleteEmptySeasons(LibraryPath libraryPath);
Task<Unit> DeleteEmptyShows(LibraryPath libraryPath);
Task<Either<BaseError, 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<Unit> AddGenre(ShowMetadata metadata, Genre genre);
Task<Unit> RemoveMissingPlexShows(PlexLibrary library, List<string> showKeys);
Task<Unit> RemoveMissingPlexSeasons(string showKey, List<string> seasonKeys);
Task<Unit> RemoveMissingPlexEpisodes(string seasonKey, List<string> episodeKeys);
}
}
+75 -6
View File
@@ -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)
+9 -1
View File
@@ -80,7 +80,15 @@ 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, Unit> refreshResult =
await _localStatisticsProvider.RefreshStatistics(ffprobePath, mediaItem);
refreshResult.IfLeft(
error =>
_logger.LogWarning(
"Unable to refresh {Attribute} for media item {Path}. Error: {Error}",
"Statistics",
path,
error.Value));
}
return mediaItem;
@@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using System.Xml.Serialization;
using ErsatzTV.Core.Domain;
@@ -47,7 +48,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);
@@ -100,11 +106,15 @@ namespace ErsatzTV.Core.Metadata
existing.OriginalTitle = metadata.OriginalTitle;
existing.ReleaseDate = metadata.ReleaseDate;
existing.Year = metadata.Year;
existing.SortTitle = metadata.SortTitle ?? _fallbackMetadataProvider.GetSortTitle(metadata.Title);
existing.SortTitle = string.IsNullOrWhiteSpace(metadata.SortTitle)
? _fallbackMetadataProvider.GetSortTitle(metadata.Title)
: metadata.SortTitle;
},
() =>
{
metadata.SortTitle ??= _fallbackMetadataProvider.GetSortTitle(metadata.Title);
metadata.SortTitle = string.IsNullOrWhiteSpace(metadata.SortTitle)
? _fallbackMetadataProvider.GetSortTitle(metadata.Title)
: metadata.SortTitle;
episode.EpisodeMetadata = new List<EpisodeMetadata> { metadata };
});
@@ -126,11 +136,39 @@ namespace ErsatzTV.Core.Metadata
existing.OriginalTitle = metadata.OriginalTitle;
existing.ReleaseDate = metadata.ReleaseDate;
existing.Year = metadata.Year;
existing.SortTitle = metadata.SortTitle ?? _fallbackMetadataProvider.GetSortTitle(metadata.Title);
existing.SortTitle = string.IsNullOrWhiteSpace(metadata.SortTitle)
? _fallbackMetadataProvider.GetSortTitle(metadata.Title)
: metadata.SortTitle;
foreach (Genre genre in existing.Genres.Filter(g => metadata.Genres.All(g2 => g2.Name != g.Name))
.ToList())
{
existing.Genres.Remove(genre);
}
foreach (Genre genre in metadata.Genres.Filter(g => existing.Genres.All(g2 => g2.Name != g.Name))
.ToList())
{
existing.Genres.Add(genre);
}
foreach (Tag tag in existing.Tags.Filter(t => metadata.Tags.All(t2 => t2.Name != t.Name))
.ToList())
{
existing.Tags.Remove(tag);
}
foreach (Tag tag in metadata.Tags.Filter(t => existing.Tags.All(t2 => t2.Name != t.Name))
.ToList())
{
existing.Tags.Add(tag);
}
},
() =>
{
metadata.SortTitle ??= _fallbackMetadataProvider.GetSortTitle(metadata.Title);
metadata.SortTitle = string.IsNullOrWhiteSpace(metadata.SortTitle)
? _fallbackMetadataProvider.GetSortTitle(metadata.Title)
: metadata.SortTitle;
movie.MovieMetadata = new List<MovieMetadata> { metadata };
});
@@ -152,11 +190,39 @@ namespace ErsatzTV.Core.Metadata
existing.OriginalTitle = metadata.OriginalTitle;
existing.ReleaseDate = metadata.ReleaseDate;
existing.Year = metadata.Year;
existing.SortTitle = metadata.SortTitle ?? _fallbackMetadataProvider.GetSortTitle(metadata.Title);
existing.SortTitle = string.IsNullOrWhiteSpace(metadata.SortTitle)
? _fallbackMetadataProvider.GetSortTitle(metadata.Title)
: metadata.SortTitle;
foreach (Genre genre in existing.Genres.Filter(g => metadata.Genres.All(g2 => g2.Name != g.Name))
.ToList())
{
existing.Genres.Remove(genre);
}
foreach (Genre genre in metadata.Genres.Filter(g => existing.Genres.All(g2 => g2.Name != g.Name))
.ToList())
{
existing.Genres.Add(genre);
}
foreach (Tag tag in existing.Tags.Filter(t => metadata.Tags.All(t2 => t2.Name != t.Name))
.ToList())
{
existing.Tags.Remove(tag);
}
foreach (Tag tag in metadata.Tags.Filter(t => existing.Tags.All(t2 => t2.Name != t.Name))
.ToList())
{
existing.Tags.Add(tag);
}
},
() =>
{
metadata.SortTitle ??= _fallbackMetadataProvider.GetSortTitle(metadata.Title);
metadata.SortTitle = string.IsNullOrWhiteSpace(metadata.SortTitle)
? _fallbackMetadataProvider.GetSortTitle(metadata.Title)
: metadata.SortTitle;
show.ShowMetadata = new List<ShowMetadata> { metadata };
});
@@ -212,7 +278,9 @@ namespace ErsatzTV.Core.Metadata
Outline = nfo.Outline,
Tagline = nfo.Tagline,
Year = nfo.Year,
ReleaseDate = GetAired(nfo.Premiered) ?? new DateTime(nfo.Year, 1, 1)
ReleaseDate = GetAired(nfo.Premiered) ?? new DateTime(nfo.Year, 1, 1),
Genres = nfo.Genres.Map(g => new Genre { Name = g }).ToList(),
Tags = nfo.Tags.Map(t => new Tag { Name = t }).ToList()
},
None);
}
@@ -267,7 +335,9 @@ namespace ErsatzTV.Core.Metadata
ReleaseDate = nfo.Premiered,
Plot = nfo.Plot,
Outline = nfo.Outline,
Tagline = nfo.Tagline
Tagline = nfo.Tagline,
Genres = nfo.Genres.Map(g => new Genre { Name = g }).ToList(),
Tags = nfo.Tags.Map(t => new Tag { Name = t }).ToList()
},
None);
}
@@ -316,6 +386,12 @@ namespace ErsatzTV.Core.Metadata
[XmlElement("tagline")]
public string Tagline { get; set; }
[XmlElement("genre")]
public List<string> Genres { get; set; }
[XmlElement("tag")]
public List<string> Tags { get; set; }
}
[XmlRoot("tvshow")]
@@ -338,6 +414,12 @@ namespace ErsatzTV.Core.Metadata
[XmlElement("premiered")]
public string Premiered { get; set; }
[XmlElement("genre")]
public List<string> Genres { get; set; }
[XmlElement("tag")]
public List<string> Tags { get; set; }
}
[XmlRoot("episodedetails")]
@@ -29,7 +29,7 @@ namespace ErsatzTV.Core.Metadata
_logger = logger;
}
public async Task<bool> RefreshStatistics(string ffprobePath, MediaItem mediaItem)
public async Task<Either<BaseError, Unit>> 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);
await ApplyVersionUpdate(mediaItem, version, filePath);
return Right<BaseError, Unit>(Unit.Default);
},
error => Task.FromResult(Left<BaseError, Unit>(error)));
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to refresh statistics for media item {Id}", mediaItem.Id);
return false;
return BaseError.New(ex.Message);
}
}
@@ -76,7 +82,7 @@ namespace ErsatzTV.Core.Metadata
return await _mediaItemRepository.Update(mediaItem) && 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}");
});
}
+5 -27
View File
@@ -80,8 +80,8 @@ namespace ErsatzTV.Core.Metadata
.GetOrAdd(libraryPath, file)
.BindT(movie => UpdateStatistics(movie, ffprobePath).MapT(_ => movie))
.BindT(UpdateMetadata)
.BindT(UpdatePoster)
.BindT(UpdateFanArt);
.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));
@@ -136,37 +136,15 @@ namespace ErsatzTV.Core.Metadata
}
}
private async Task<Either<BaseError, Movie>> UpdatePoster(Movie movie)
private async Task<Either<BaseError, Movie>> UpdateArtwork(Movie movie, ArtworkKind artworkKind)
{
try
{
await LocateArtwork(movie, ArtworkKind.Poster).IfSomeAsync(
await LocateArtwork(movie, artworkKind).IfSomeAsync(
async posterFile =>
{
MovieMetadata metadata = movie.MovieMetadata.Head();
if (RefreshArtwork(posterFile, metadata, ArtworkKind.Poster))
{
await _movieRepository.Update(movie);
}
});
return movie;
}
catch (Exception ex)
{
return BaseError.New(ex.Message);
}
}
private async Task<Either<BaseError, Movie>> UpdateFanArt(Movie movie)
{
try
{
await LocateArtwork(movie, ArtworkKind.FanArt).IfSomeAsync(
async posterFile =>
{
MovieMetadata metadata = movie.MovieMetadata.Head();
if (RefreshArtwork(posterFile, metadata, ArtworkKind.FanArt))
if (RefreshArtwork(posterFile, metadata, artworkKind))
{
await _movieRepository.Update(movie);
}
@@ -56,7 +56,8 @@ namespace ErsatzTV.Core.Metadata
Either<BaseError, Show> maybeShow =
await FindOrCreateShow(libraryPath.Id, showFolder)
.BindT(show => UpdateMetadataForShow(show, showFolder))
.BindT(show => UpdatePosterForShow(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),
@@ -212,17 +213,18 @@ namespace ErsatzTV.Core.Metadata
}
}
private async Task<Either<BaseError, Show>> UpdatePosterForShow(
private async Task<Either<BaseError, Show>> UpdateArtworkForShow(
Show show,
string showFolder)
string showFolder,
ArtworkKind artworkKind)
{
try
{
await LocatePosterForShow(showFolder).IfSomeAsync(
await LocateArtworkForShow(showFolder, artworkKind).IfSomeAsync(
async posterFile =>
{
ShowMetadata metadata = show.ShowMetadata.Head();
if (RefreshArtwork(posterFile, metadata, ArtworkKind.Poster))
if (RefreshArtwork(posterFile, metadata, artworkKind))
{
await _televisionRepository.Update(show);
}
@@ -298,12 +300,21 @@ namespace ErsatzTV.Core.Metadata
.Filter(s => _localFileSystem.FileExists(s));
}
private Option<string> LocatePosterForShow(string showFolder) =>
ImageFileExtensions
.Map(ext => $"poster.{ext}")
private Option<string> LocateArtworkForShow(string showFolder, ArtworkKind artworkKind)
{
string segment = artworkKind switch
{
ArtworkKind.Poster => "poster",
ArtworkKind.FanArt => "fanart",
_ => throw new ArgumentOutOfRangeException(nameof(artworkKind))
};
return ImageFileExtensions
.Map(ext => $"{segment}.{ext}")
.Map(f => Path.Combine(showFolder, f))
.Filter(s => _localFileSystem.FileExists(s))
.HeadOrNone();
}
private Option<string> LocatePoster(Season season, string seasonFolder)
{
+58
View File
@@ -0,0 +1,58 @@
using System.Collections.Generic;
using System.Threading.Tasks;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using LanguageExt;
using static LanguageExt.Prelude;
namespace ErsatzTV.Core.Plex
{
public abstract class PlexLibraryScanner
{
private readonly IMetadataRepository _metadataRepository;
protected PlexLibraryScanner(IMetadataRepository metadataRepository) =>
_metadataRepository = metadataRepository;
protected async Task<Unit> UpdateArtworkIfNeeded(
Domain.Metadata existingMetadata,
Domain.Metadata incomingMetadata,
ArtworkKind artworkKind)
{
if (incomingMetadata.DateUpdated > existingMetadata.DateUpdated)
{
Option<Artwork> maybeIncomingArtwork = Optional(incomingMetadata.Artwork).Flatten()
.Find(a => a.ArtworkKind == artworkKind);
await maybeIncomingArtwork.Match(
async incomingArtwork =>
{
Option<Artwork> maybeExistingArtwork = Optional(existingMetadata.Artwork).Flatten()
.Find(a => a.ArtworkKind == artworkKind);
await maybeExistingArtwork.Match(
async existingArtwork =>
{
existingArtwork.Path = incomingArtwork.Path;
existingArtwork.DateUpdated = incomingArtwork.DateUpdated;
await _metadataRepository.UpdateArtworkPath(existingArtwork);
},
async () =>
{
existingMetadata.Artwork ??= new List<Artwork>();
existingMetadata.Artwork.Add(incomingArtwork);
await _metadataRepository.AddArtwork(existingMetadata, incomingArtwork);
});
},
async () =>
{
existingMetadata.Artwork ??= new List<Artwork>();
existingMetadata.Artwork.RemoveAll(a => a.ArtworkKind == artworkKind);
await _metadataRepository.RemoveArtwork(existingMetadata, artworkKind);
});
}
return Unit.Default;
}
}
}
+86 -14
View File
@@ -1,4 +1,5 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Plex;
@@ -9,19 +10,23 @@ using static LanguageExt.Prelude;
namespace ErsatzTV.Core.Plex
{
public class PlexMovieLibraryScanner : IPlexMovieLibraryScanner
public class PlexMovieLibraryScanner : PlexLibraryScanner, IPlexMovieLibraryScanner
{
private readonly ILogger<PlexMovieLibraryScanner> _logger;
private readonly IMetadataRepository _metadataRepository;
private readonly IMovieRepository _movieRepository;
private readonly IPlexServerApiClient _plexServerApiClient;
public PlexMovieLibraryScanner(
IPlexServerApiClient plexServerApiClient,
IMovieRepository movieRepository,
IMetadataRepository metadataRepository,
ILogger<PlexMovieLibraryScanner> logger)
: base(metadataRepository)
{
_plexServerApiClient = plexServerApiClient;
_movieRepository = movieRepository;
_metadataRepository = metadataRepository;
_logger = logger;
}
@@ -30,7 +35,7 @@ namespace ErsatzTV.Core.Plex
PlexServerAuthToken token,
PlexLibrary plexMediaSourceLibrary)
{
Either<BaseError, List<PlexMovie>> entries = await _plexServerApiClient.GetLibraryContents(
Either<BaseError, List<PlexMovie>> entries = await _plexServerApiClient.GetMovieLibraryContents(
plexMediaSourceLibrary,
connection,
token);
@@ -38,20 +43,24 @@ namespace ErsatzTV.Core.Plex
await entries.Match(
async movieEntries =>
{
foreach (PlexMovie entry in movieEntries)
foreach (PlexMovie incoming in movieEntries)
{
// 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, PlexMovie> maybeMovie = await _movieRepository
.GetOrAdd(plexMediaSourceLibrary, entry)
.BindT(UpdateIfNeeded);
.GetOrAdd(plexMediaSourceLibrary, incoming)
.BindT(existing => UpdateStatistics(existing, incoming, connection, token))
.BindT(existing => UpdateMetadata(existing, incoming))
.BindT(existing => UpdateArtwork(existing, incoming));
maybeMovie.IfLeft(
error => _logger.LogWarning(
"Error processing plex movie at {Key}: {Error}",
entry.Key,
incoming.Key,
error.Value));
}
var movieKeys = movieEntries.Map(s => s.Key).ToList();
await _movieRepository.RemoveMissingPlexMovies(plexMediaSourceLibrary, movieKeys);
},
error =>
{
@@ -63,15 +72,78 @@ namespace ErsatzTV.Core.Plex
return Task.CompletedTask;
});
// need plex media item model that can be used to lookup by unique id (metadata key?)
return Unit.Default;
}
private Task<Either<BaseError, PlexMovie>> UpdateIfNeeded(PlexMovie plexMovie) =>
// .BindT(movie => UpdateStatistics(movie, ffprobePath).MapT(_ => movie))
// .BindT(UpdateMetadata)
// .BindT(UpdatePoster);
Right<BaseError, PlexMovie>(plexMovie).AsTask();
private async Task<Either<BaseError, PlexMovie>> UpdateStatistics(
PlexMovie existing,
PlexMovie incoming,
PlexConnection connection,
PlexServerAuthToken token)
{
MediaVersion existingVersion = existing.MediaVersions.Head();
MediaVersion incomingVersion = incoming.MediaVersions.Head();
if (incomingVersion.DateUpdated > existingVersion.DateUpdated ||
string.IsNullOrWhiteSpace(existingVersion.SampleAspectRatio))
{
Either<BaseError, MediaVersion> maybeStatistics =
await _plexServerApiClient.GetStatistics(incoming.Key.Split("/").Last(), connection, token);
await maybeStatistics.Match(
async mediaVersion =>
{
existingVersion.SampleAspectRatio = mediaVersion.SampleAspectRatio ?? "1:1";
existingVersion.VideoScanKind = mediaVersion.VideoScanKind;
existingVersion.DateUpdated = incomingVersion.DateUpdated;
await _metadataRepository.UpdateStatistics(existingVersion);
},
_ => Task.CompletedTask);
}
return Right<BaseError, PlexMovie>(existing);
}
private async Task<Either<BaseError, PlexMovie>> UpdateMetadata(PlexMovie existing, PlexMovie incoming)
{
MovieMetadata existingMetadata = existing.MovieMetadata.Head();
MovieMetadata incomingMetadata = incoming.MovieMetadata.Head();
if (incomingMetadata.DateUpdated > existingMetadata.DateUpdated)
{
foreach (Genre genre in existingMetadata.Genres
.Filter(g => incomingMetadata.Genres.All(g2 => g2.Name != g.Name))
.ToList())
{
existingMetadata.Genres.Remove(genre);
await _metadataRepository.RemoveGenre(genre);
}
foreach (Genre genre in incomingMetadata.Genres
.Filter(g => existingMetadata.Genres.All(g2 => g2.Name != g.Name))
.ToList())
{
existingMetadata.Genres.Add(genre);
await _movieRepository.AddGenre(existingMetadata, genre);
}
}
return existing;
}
private async Task<Either<BaseError, PlexMovie>> UpdateArtwork(PlexMovie existing, PlexMovie incoming)
{
MovieMetadata existingMetadata = existing.MovieMetadata.Head();
MovieMetadata incomingMetadata = incoming.MovieMetadata.Head();
if (incomingMetadata.DateUpdated > existingMetadata.DateUpdated)
{
await UpdateArtworkIfNeeded(existingMetadata, incomingMetadata, ArtworkKind.Poster);
await UpdateArtworkIfNeeded(existingMetadata, incomingMetadata, ArtworkKind.FanArt);
}
return existing;
}
}
}
@@ -0,0 +1,281 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Plex;
using ErsatzTV.Core.Interfaces.Repositories;
using LanguageExt;
using Microsoft.Extensions.Logging;
using static LanguageExt.Prelude;
namespace ErsatzTV.Core.Plex
{
public class PlexTelevisionLibraryScanner : PlexLibraryScanner, IPlexTelevisionLibraryScanner
{
private readonly ILogger<PlexTelevisionLibraryScanner> _logger;
private readonly IMetadataRepository _metadataRepository;
private readonly IPlexServerApiClient _plexServerApiClient;
private readonly ITelevisionRepository _televisionRepository;
public PlexTelevisionLibraryScanner(
IPlexServerApiClient plexServerApiClient,
ITelevisionRepository televisionRepository,
IMetadataRepository metadataRepository,
ILogger<PlexTelevisionLibraryScanner> logger)
: base(metadataRepository)
{
_plexServerApiClient = plexServerApiClient;
_televisionRepository = televisionRepository;
_metadataRepository = metadataRepository;
_logger = logger;
}
public async Task<Either<BaseError, Unit>> ScanLibrary(
PlexConnection connection,
PlexServerAuthToken token,
PlexLibrary plexMediaSourceLibrary)
{
Either<BaseError, List<PlexShow>> entries = await _plexServerApiClient.GetShowLibraryContents(
plexMediaSourceLibrary,
connection,
token);
return await entries.Match<Task<Either<BaseError, Unit>>>(
async showEntries =>
{
foreach (PlexShow incoming in showEntries)
{
// TODO: figure out how to rebuild playlists
Either<BaseError, PlexShow> maybeShow = await _televisionRepository
.GetOrAddPlexShow(plexMediaSourceLibrary, incoming)
.BindT(existing => UpdateMetadata(existing, incoming))
.BindT(existing => UpdateArtwork(existing, incoming));
await maybeShow.Match(
async show => await ScanSeasons(plexMediaSourceLibrary, show, connection, token),
error =>
{
_logger.LogWarning(
"Error processing plex show at {Key}: {Error}",
incoming.Key,
error.Value);
return Task.CompletedTask;
});
}
var showKeys = showEntries.Map(s => s.Key).ToList();
await _televisionRepository.RemoveMissingPlexShows(plexMediaSourceLibrary, showKeys);
return Unit.Default;
},
error =>
{
_logger.LogWarning(
"Error synchronizing plex library {Path}: {Error}",
plexMediaSourceLibrary.Name,
error.Value);
return Left<BaseError, Unit>(error).AsTask();
});
}
private Task<Either<BaseError, PlexShow>> UpdateMetadata(PlexShow existing, PlexShow incoming)
{
ShowMetadata existingMetadata = existing.ShowMetadata.Head();
ShowMetadata incomingMetadata = incoming.ShowMetadata.Head();
// TODO: this probably doesn't work
// plex doesn't seem to update genres returned by the main library call
if (incomingMetadata.DateUpdated > existingMetadata.DateUpdated)
{
foreach (Genre genre in existingMetadata.Genres
.Filter(g => incomingMetadata.Genres.All(g2 => g2.Name != g.Name))
.ToList())
{
existingMetadata.Genres.Remove(genre);
_metadataRepository.RemoveGenre(genre);
}
foreach (Genre genre in incomingMetadata.Genres
.Filter(g => existingMetadata.Genres.All(g2 => g2.Name != g.Name))
.ToList())
{
existingMetadata.Genres.Add(genre);
_televisionRepository.AddGenre(existingMetadata, genre);
}
}
return Right<BaseError, PlexShow>(existing).AsTask();
}
private async Task<Either<BaseError, PlexShow>> UpdateArtwork(PlexShow existing, PlexShow incoming)
{
ShowMetadata existingMetadata = existing.ShowMetadata.Head();
ShowMetadata incomingMetadata = incoming.ShowMetadata.Head();
if (incomingMetadata.DateUpdated > existingMetadata.DateUpdated)
{
await UpdateArtworkIfNeeded(existingMetadata, incomingMetadata, ArtworkKind.Poster);
await UpdateArtworkIfNeeded(existingMetadata, incomingMetadata, ArtworkKind.FanArt);
}
return existing;
}
private async Task<Either<BaseError, Unit>> ScanSeasons(
PlexLibrary plexMediaSourceLibrary,
PlexShow show,
PlexConnection connection,
PlexServerAuthToken token)
{
Either<BaseError, List<PlexSeason>> entries = await _plexServerApiClient.GetShowSeasons(
plexMediaSourceLibrary,
show,
connection,
token);
return await entries.Match<Task<Either<BaseError, Unit>>>(
async seasonEntries =>
{
foreach (PlexSeason incoming in seasonEntries)
{
incoming.ShowId = show.Id;
// TODO: figure out how to rebuild playlists
Either<BaseError, PlexSeason> maybeSeason = await _televisionRepository
.GetOrAddPlexSeason(plexMediaSourceLibrary, incoming)
.BindT(existing => UpdateArtwork(existing, incoming));
await maybeSeason.Match(
async season => await ScanEpisodes(plexMediaSourceLibrary, season, connection, token),
error =>
{
_logger.LogWarning(
"Error processing plex show at {Key}: {Error}",
incoming.Key,
error.Value);
return Task.CompletedTask;
});
}
var seasonKeys = seasonEntries.Map(s => s.Key).ToList();
await _televisionRepository.RemoveMissingPlexSeasons(show.Key, seasonKeys);
return Unit.Default;
},
error =>
{
_logger.LogWarning(
"Error synchronizing plex library {Path}: {Error}",
plexMediaSourceLibrary.Name,
error.Value);
return Left<BaseError, Unit>(error).AsTask();
});
}
private async Task<Either<BaseError, PlexSeason>> UpdateArtwork(PlexSeason existing, PlexSeason incoming)
{
SeasonMetadata existingMetadata = existing.SeasonMetadata.Head();
SeasonMetadata incomingMetadata = incoming.SeasonMetadata.Head();
if (incomingMetadata.DateUpdated > existingMetadata.DateUpdated)
{
await UpdateArtworkIfNeeded(existingMetadata, incomingMetadata, ArtworkKind.Poster);
}
return existing;
}
private async Task<Either<BaseError, Unit>> ScanEpisodes(
PlexLibrary plexMediaSourceLibrary,
PlexSeason season,
PlexConnection connection,
PlexServerAuthToken token)
{
Either<BaseError, List<PlexEpisode>> entries = await _plexServerApiClient.GetSeasonEpisodes(
plexMediaSourceLibrary,
season,
connection,
token);
return await entries.Match<Task<Either<BaseError, Unit>>>(
async episodeEntries =>
{
foreach (PlexEpisode incoming in episodeEntries)
{
incoming.SeasonId = season.Id;
// TODO: figure out how to rebuild playlists
Either<BaseError, PlexEpisode> maybeEpisode = await _televisionRepository
.GetOrAddPlexEpisode(plexMediaSourceLibrary, incoming)
.BindT(existing => UpdateStatistics(existing, incoming, connection, token))
.BindT(existing => UpdateArtwork(existing, incoming));
maybeEpisode.IfLeft(
error => _logger.LogWarning(
"Error processing plex episode at {Key}: {Error}",
incoming.Key,
error.Value));
}
var episodeKeys = episodeEntries.Map(s => s.Key).ToList();
await _televisionRepository.RemoveMissingPlexEpisodes(season.Key, episodeKeys);
return Unit.Default;
},
error =>
{
_logger.LogWarning(
"Error synchronizing plex library {Path}: {Error}",
plexMediaSourceLibrary.Name,
error.Value);
return Left<BaseError, Unit>(error).AsTask();
});
}
private async Task<Either<BaseError, PlexEpisode>> UpdateStatistics(
PlexEpisode existing,
PlexEpisode incoming,
PlexConnection connection,
PlexServerAuthToken token)
{
MediaVersion existingVersion = existing.MediaVersions.Head();
MediaVersion incomingVersion = incoming.MediaVersions.Head();
if (incomingVersion.DateUpdated > existingVersion.DateUpdated ||
string.IsNullOrWhiteSpace(existingVersion.SampleAspectRatio))
{
Either<BaseError, MediaVersion> maybeStatistics =
await _plexServerApiClient.GetStatistics(incoming.Key.Split("/").Last(), connection, token);
await maybeStatistics.Match(
async mediaVersion =>
{
existingVersion.SampleAspectRatio = mediaVersion.SampleAspectRatio ?? "1:1";
existingVersion.VideoScanKind = mediaVersion.VideoScanKind;
existingVersion.DateUpdated = incomingVersion.DateUpdated;
await _metadataRepository.UpdateStatistics(existingVersion);
},
_ => Task.CompletedTask);
}
return Right<BaseError, PlexEpisode>(existing);
}
private async Task<Either<BaseError, PlexEpisode>> UpdateArtwork(PlexEpisode existing, PlexEpisode incoming)
{
EpisodeMetadata existingMetadata = existing.EpisodeMetadata.Head();
EpisodeMetadata incomingMetadata = incoming.EpisodeMetadata.Head();
if (incomingMetadata.DateUpdated > existingMetadata.DateUpdated)
{
await UpdateArtworkIfNeeded(existingMetadata, incomingMetadata, ArtworkKind.Thumbnail);
}
return existing;
}
}
}
@@ -0,0 +1,38 @@
using System.Collections.Generic;
using System.Linq;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Scheduling;
using LanguageExt;
using static LanguageExt.Prelude;
namespace ErsatzTV.Core.Scheduling
{
public class CustomOrderCollectionEnumerator : IMediaCollectionEnumerator
{
private readonly IList<MediaItem> _sortedMediaItems;
public CustomOrderCollectionEnumerator(
Collection collection,
List<MediaItem> mediaItems,
CollectionEnumeratorState state)
{
// TODO: this will break if we allow shows and seasons
_sortedMediaItems = collection.CollectionItems
.OrderBy(ci => ci.CustomIndex)
.Map(ci => mediaItems.First(mi => mi.Id == ci.MediaItemId))
.ToList();
State = new CollectionEnumeratorState { Seed = state.Seed };
while (State.Index < state.Index)
{
MoveNext();
}
}
public CollectionEnumeratorState State { get; }
public Option<MediaItem> Current => _sortedMediaItems.Any() ? _sortedMediaItems[State.Index] : None;
public void MoveNext() => State.Index = (State.Index + 1) % _sortedMediaItems.Count;
}
}
+67 -23
View File
@@ -13,6 +13,8 @@ using Map = LanguageExt.Map;
namespace ErsatzTV.Core.Scheduling
{
// TODO: these tests fail on days when offset changes
// because the change happens during the playout
public class PlayoutBuilder : IPlayoutBuilder
{
private static readonly Random Random = new();
@@ -78,7 +80,8 @@ namespace ErsatzTV.Core.Scheduling
playout.Channel.Number,
playout.Channel.Name);
Option<CollectionKey> emptyCollection = collectionMediaItems.Find(c => !c.Value.Any()).Map(c => c.Key);
Option<CollectionKey> emptyCollection =
collectionMediaItems.Find(c => !c.Value.Any()).Map(c => c.Key);
if (emptyCollection.IsSome)
{
_logger.LogError(
@@ -88,6 +91,25 @@ namespace ErsatzTV.Core.Scheduling
return playout;
}
Option<CollectionKey> zeroDurationCollection = collectionMediaItems.Find(
c => c.Value.Any(
mi => mi switch
{
Movie m => m.MediaVersions.HeadOrNone().Map(mv => mv.Duration).IfNone(TimeSpan.Zero) ==
TimeSpan.Zero,
Episode e => e.MediaVersions.HeadOrNone().Map(mv => mv.Duration).IfNone(TimeSpan.Zero) ==
TimeSpan.Zero,
_ => true
})).Map(c => c.Key);
if (zeroDurationCollection.IsSome)
{
_logger.LogError(
"Unable to rebuild playout; collection {@CollectionKey} contains items with zero duration!",
zeroDurationCollection.ValueUnsafe());
return playout;
}
playout.Items ??= new List<PlayoutItem>();
playout.ProgramScheduleAnchors ??= new List<PlayoutProgramScheduleAnchor>();
@@ -98,15 +120,21 @@ namespace ErsatzTV.Core.Scheduling
playout.ProgramScheduleAnchors.Clear();
}
var sortedScheduleItems = playout.ProgramSchedule.Items.OrderBy(i => i.Index).ToList();
Map<CollectionKey, IMediaCollectionEnumerator> collectionEnumerators =
MapExtensions.Map(collectionMediaItems, (c, i) => GetMediaCollectionEnumerator(playout, c, i));
var sortedScheduleItems =
playout.ProgramSchedule.Items.OrderBy(i => i.Index).ToList();
var collectionEnumerators = new Dictionary<CollectionKey, IMediaCollectionEnumerator>();
foreach ((CollectionKey collectionKey, List<MediaItem> mediaItems) in collectionMediaItems)
{
IMediaCollectionEnumerator enumerator =
await GetMediaCollectionEnumerator(playout, collectionKey, mediaItems);
collectionEnumerators.Add(collectionKey, enumerator);
}
// find start anchor
PlayoutAnchor startAnchor = FindStartAnchor(playout, playoutStart, sortedScheduleItems);
// start at the previously-decided time
DateTimeOffset currentTime = startAnchor.NextStart;
DateTimeOffset currentTime = startAnchor.NextStartOffset.ToLocalTime();
_logger.LogDebug(
"Starting playout {PlayoutId} for channel {ChannelNumber} - {ChannelName} at {StartTime}",
playout.Id,
@@ -277,7 +305,7 @@ namespace ErsatzTV.Core.Scheduling
playout.ProgramScheduleAnchors = BuildProgramScheduleAnchors(playout, collectionEnumerators);
// remove any items outside the desired range
playout.Items.RemoveAll(old => old.Finish < playoutStart || old.Start > playoutFinish);
playout.Items.RemoveAll(old => old.FinishOffset < playoutStart || old.StartOffset > playoutFinish);
return playout;
}
@@ -297,7 +325,8 @@ namespace ErsatzTV.Core.Scheduling
{
NextScheduleItem = schedule,
NextScheduleItemId = schedule.Id,
NextStart = start.Date + schedule.StartTime.GetValueOrDefault()
NextStart = (start - start.TimeOfDay).UtcDateTime +
schedule.StartTime.GetValueOrDefault()
};
case StartType.Dynamic:
default:
@@ -305,7 +334,7 @@ namespace ErsatzTV.Core.Scheduling
{
NextScheduleItem = schedule,
NextScheduleItemId = schedule.Id,
NextStart = start.Date
NextStart = (start - start.TimeOfDay).UtcDateTime
};
}
});
@@ -337,20 +366,20 @@ namespace ErsatzTV.Core.Scheduling
private static List<PlayoutProgramScheduleAnchor> BuildProgramScheduleAnchors(
Playout playout,
Map<CollectionKey, IMediaCollectionEnumerator> collectionEnumerators)
Dictionary<CollectionKey, IMediaCollectionEnumerator> collectionEnumerators)
{
var result = new List<PlayoutProgramScheduleAnchor>();
foreach (CollectionKey collectionKey in collectionEnumerators.Keys)
{
Option<PlayoutProgramScheduleAnchor> maybeExisting = playout.ProgramScheduleAnchors
.FirstOrDefault(
a => a.CollectionType == collectionKey.CollectionType
&& a.CollectionId == collectionKey.CollectionId
&& a.MediaItemId == collectionKey.MediaItemId);
Option<PlayoutProgramScheduleAnchor> maybeExisting = playout.ProgramScheduleAnchors.FirstOrDefault(
a => a.CollectionType == collectionKey.CollectionType
&& a.CollectionId == collectionKey.CollectionId
&& a.MediaItemId == collectionKey.MediaItemId);
var maybeEnumeratorState = collectionEnumerators.GroupBy(e => e.Key, e => e.Value.State)
.ToDictionary(mcs => mcs.Key, mcs => mcs.Head());
var maybeEnumeratorState = collectionEnumerators.GroupBy(e => e.Key, e => e.Value.State).ToDictionary(
mcs => mcs.Key,
mcs => mcs.Head());
PlayoutProgramScheduleAnchor scheduleAnchor = maybeExisting.Match(
existing =>
@@ -376,22 +405,37 @@ namespace ErsatzTV.Core.Scheduling
return result;
}
private static IMediaCollectionEnumerator GetMediaCollectionEnumerator(
private async Task<IMediaCollectionEnumerator> GetMediaCollectionEnumerator(
Playout playout,
CollectionKey collectionKey,
List<MediaItem> mediaItems)
{
Option<PlayoutProgramScheduleAnchor> maybeAnchor = playout.ProgramScheduleAnchors
.FirstOrDefault(
a => a.ProgramScheduleId == playout.ProgramScheduleId
&& a.CollectionType == collectionKey.CollectionType
&& a.CollectionId == collectionKey.CollectionId
&& a.MediaItemId == collectionKey.MediaItemId);
Option<PlayoutProgramScheduleAnchor> maybeAnchor = playout.ProgramScheduleAnchors.FirstOrDefault(
a => a.ProgramScheduleId == playout.ProgramScheduleId
&& a.CollectionType == collectionKey.CollectionType
&& a.CollectionId == collectionKey.CollectionId
&& a.MediaItemId == collectionKey.MediaItemId);
CollectionEnumeratorState state = maybeAnchor.Match(
anchor => anchor.EnumeratorState,
() => new CollectionEnumeratorState { Seed = Random.Next(), Index = 0 });
if (await _mediaCollectionRepository.IsCustomPlaybackOrder(collectionKey.CollectionId ?? 0))
{
Option<Collection> collectionWithItems =
await _mediaCollectionRepository.GetCollectionWithCollectionItemsUntracked(
collectionKey.CollectionId ?? 0);
if (collectionKey.CollectionType == ProgramScheduleItemCollectionType.Collection &&
collectionWithItems.IsSome)
{
return new CustomOrderCollectionEnumerator(
collectionWithItems.ValueUnsafe(),
mediaItems,
state);
}
}
switch (playout.ProgramSchedule.MediaCollectionPlaybackOrder)
{
case PlaybackOrder.Chronological:
@@ -34,13 +34,21 @@ namespace ErsatzTV.Core.Scheduling
public void MoveNext()
{
State.Index++;
if (State.Index % _shuffled.Count == 0)
if ((State.Index + 1) % _shuffled.Count == 0)
{
Option<MediaItem> tail = Current;
State.Index = 0;
State.Seed = _random.Next();
_random = new Random(State.Seed);
_shuffled = Shuffle(_mediaItems, _random);
do
{
State.Seed = _random.Next();
_random = new Random(State.Seed);
_shuffled = Shuffle(_mediaItems, _random);
} while (_mediaItems.Count > 1 && Current == tail);
}
else
{
State.Index++;
}
State.Index %= _shuffled.Count;
@@ -0,0 +1,11 @@
using ErsatzTV.Core.Domain;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ErsatzTV.Infrastructure.Data.Configurations
{
public class CollectionItemConfiguration : IEntityTypeConfiguration<CollectionItem>
{
public void Configure(EntityTypeBuilder<CollectionItem> builder) => builder.ToTable("CollectionItem");
}
}
@@ -0,0 +1,11 @@
using ErsatzTV.Core.Domain;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ErsatzTV.Infrastructure.Data.Configurations
{
public class PlexEpisodeConfiguration : IEntityTypeConfiguration<PlexEpisode>
{
public void Configure(EntityTypeBuilder<PlexEpisode> builder) => builder.ToTable("PlexEpisode");
}
}
@@ -0,0 +1,11 @@
using ErsatzTV.Core.Domain;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ErsatzTV.Infrastructure.Data.Configurations
{
public class PlexSeasonConfiguration : IEntityTypeConfiguration<PlexSeason>
{
public void Configure(EntityTypeBuilder<PlexSeason> builder) => builder.ToTable("PlexSeason");
}
}
@@ -0,0 +1,11 @@
using ErsatzTV.Core.Domain;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ErsatzTV.Infrastructure.Data.Configurations
{
public class PlexShowConfiguration : IEntityTypeConfiguration<PlexShow>
{
public void Configure(EntityTypeBuilder<PlexShow> builder) => builder.ToTable("PlexShow");
}
}
@@ -13,6 +13,14 @@ namespace ErsatzTV.Infrastructure.Data.Configurations
builder.HasMany(mm => mm.Artwork)
.WithOne()
.OnDelete(DeleteBehavior.Cascade);
builder.HasMany(mm => mm.Genres)
.WithOne()
.OnDelete(DeleteBehavior.Cascade);
builder.HasMany(mm => mm.Tags)
.WithOne()
.OnDelete(DeleteBehavior.Cascade);
}
}
}
@@ -13,6 +13,14 @@ namespace ErsatzTV.Infrastructure.Data.Configurations
builder.HasMany(sm => sm.Artwork)
.WithOne()
.OnDelete(DeleteBehavior.Cascade);
builder.HasMany(mm => mm.Genres)
.WithOne()
.OnDelete(DeleteBehavior.Cascade);
builder.HasMany(mm => mm.Tags)
.WithOne()
.OnDelete(DeleteBehavior.Cascade);
}
}
}
@@ -45,6 +45,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
public Task<List<Channel>> GetAllForGuide() =>
_dbContext.Channels
.Include(c => c.Artwork)
.Include(c => c.Playouts)
.ThenInclude(p => p.Items)
.ThenInclude(i => i.MediaItem)
@@ -53,10 +54,14 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.ThenInclude(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as Episode).Season)
.ThenInclude(s => s.Show)
.ThenInclude(s => s.ShowMetadata)
.ThenInclude(sm => sm.Artwork)
.Include(c => c.Playouts)
.ThenInclude(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as Movie).MovieMetadata)
.ThenInclude(mm => mm.Artwork)
.ToListAsync();
public async Task Update(Channel channel)
@@ -62,8 +62,37 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
return modified;
}
public async Task<bool> AddMediaItems(int collectionId, List<int> mediaItemIds)
{
var modified = false;
Option<Collection> maybeCollection = await _dbContext.Collections
.Include(c => c.MediaItems)
.OrderBy(c => c.Id)
.SingleOrDefaultAsync(c => c.Id == collectionId)
.Map(Optional);
await maybeCollection.IfSomeAsync(
async collection =>
{
var toAdd = mediaItemIds.Filter(i => collection.MediaItems.All(i2 => i2.Id != i)).ToList();
if (toAdd.Any())
{
List<MediaItem> items = await _dbContext.MediaItems
.Filter(mi => toAdd.Contains(mi.Id))
.ToListAsync();
collection.MediaItems.AddRange(items);
modified = await _dbContext.SaveChangesAsync() > 0;
}
});
return modified;
}
public Task<Option<Collection>> Get(int id) =>
_dbContext.Collections
.Include(c => c.CollectionItems)
.OrderBy(c => c.Id)
.SingleOrDefaultAsync(c => c.Id == id)
.Map(Optional);
@@ -92,6 +121,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
public Task<Option<Collection>> GetCollectionWithItemsUntracked(int id) =>
_dbContext.Collections
.AsNoTracking()
.Include(c => c.CollectionItems)
.Include(c => c.MediaItems)
.ThenInclude(i => i.LibraryPath)
.Include(c => c.MediaItems)
@@ -117,6 +147,13 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.SingleOrDefaultAsync(c => c.Id == id)
.Map(Optional);
public Task<Option<Collection>> GetCollectionWithCollectionItemsUntracked(int id) =>
_dbContext.Collections
.Include(c => c.CollectionItems)
.OrderBy(c => c.Id)
.SingleOrDefaultAsync(c => c.Id == id)
.Map(Optional);
public Task<List<Collection>> GetAll() =>
_dbContext.Collections.ToListAsync();
@@ -146,6 +183,11 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
new { CollectionId = collectionId })
.Map(result => result.ToList());
public Task<bool> IsCustomPlaybackOrder(int collectionId) =>
_dbConnection.QuerySingleAsync<bool>(
@"SELECT IFNULL(MIN(UseCustomPlaybackOrder), 0) FROM Collection WHERE Id = @CollectionId",
new { CollectionId = collectionId });
private async Task<List<MediaItem>> GetItemsForCollection(Collection collection)
{
var result = new List<MediaItem>();
@@ -1,4 +1,5 @@
using System.Collections.Generic;
using System.Data;
using System.Linq;
using System.Threading.Tasks;
using ErsatzTV.Core.Domain;
@@ -11,18 +12,30 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
{
public class MediaItemRepository : IMediaItemRepository
{
private readonly TvContext _dbContext;
private readonly IDbConnection _dbConnection;
private readonly IDbContextFactory<TvContext> _dbContextFactory;
public MediaItemRepository(TvContext dbContext) => _dbContext = dbContext;
public MediaItemRepository(IDbContextFactory<TvContext> dbContextFactory, IDbConnection dbConnection)
{
_dbContextFactory = dbContextFactory;
_dbConnection = dbConnection;
}
public Task<Option<MediaItem>> Get(int id) =>
_dbContext.MediaItems
public async Task<Option<MediaItem>> Get(int id)
{
await using TvContext context = _dbContextFactory.CreateDbContext();
return await context.MediaItems
.Include(i => i.LibraryPath)
.OrderBy(i => i.Id)
.SingleOrDefaultAsync(i => i.Id == id)
.Map(Optional);
}
public Task<List<MediaItem>> GetAll() => _dbContext.MediaItems.ToListAsync();
public async Task<List<MediaItem>> GetAll()
{
await using TvContext context = _dbContextFactory.CreateDbContext();
return await context.MediaItems.ToListAsync();
}
public Task<List<MediaItem>> Search(string searchString) =>
// TODO: fix this when we need to search
@@ -47,8 +60,9 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
public async Task<bool> Update(MediaItem mediaItem)
{
_dbContext.MediaItems.Update(mediaItem);
return await _dbContext.SaveChangesAsync() > 0;
await using TvContext context = _dbContextFactory.CreateDbContext();
context.MediaItems.Update(mediaItem);
return await context.SaveChangesAsync() > 0;
}
}
}
@@ -160,14 +160,23 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
await context.SaveChangesAsync();
}
public async Task Delete(int id)
public async Task Delete(int mediaSourceId)
{
await using TvContext context = _dbContextFactory.CreateDbContext();
MediaSource mediaSource = await context.MediaSources.FindAsync(id);
MediaSource mediaSource = await context.MediaSources.FindAsync(mediaSourceId);
context.MediaSources.Remove(mediaSource);
await context.SaveChangesAsync();
}
public async Task<Unit> DeleteAllPlex()
{
await using TvContext context = _dbContextFactory.CreateDbContext();
List<PlexMediaSource> allMediaSources = await context.PlexMediaSources.ToListAsync();
context.PlexMediaSources.RemoveRange(allMediaSources);
await context.SaveChangesAsync();
return Unit.Default;
}
public async Task DisablePlexLibrarySync(List<int> libraryIds)
{
await _dbConnection.ExecuteAsync(
@@ -186,6 +195,33 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
INNER JOIN Library l ON l.Id = lp.LibraryId
WHERE l.Id IN @ids)",
new { ids = libraryIds });
await _dbConnection.ExecuteAsync(
@"DELETE FROM MediaItem WHERE Id IN
(SELECT m.Id FROM MediaItem m
INNER JOIN PlexEpisode pe ON pe.Id = m.Id
INNER JOIN LibraryPath lp ON lp.Id = m.LibraryPathId
INNER JOIN Library l ON l.Id = lp.LibraryId
WHERE l.Id IN @ids)",
new { ids = libraryIds });
await _dbConnection.ExecuteAsync(
@"DELETE FROM MediaItem WHERE Id IN
(SELECT m.Id FROM MediaItem m
INNER JOIN PlexSeason ps ON ps.Id = m.Id
INNER JOIN LibraryPath lp ON lp.Id = m.LibraryPathId
INNER JOIN Library l ON l.Id = lp.LibraryId
WHERE l.Id IN @ids)",
new { ids = libraryIds });
await _dbConnection.ExecuteAsync(
@"DELETE FROM MediaItem WHERE Id IN
(SELECT m.Id FROM MediaItem m
INNER JOIN PlexShow ps ON ps.Id = m.Id
INNER JOIN LibraryPath lp ON lp.Id = m.LibraryPathId
INNER JOIN Library l ON l.Id = lp.LibraryId
WHERE l.Id IN @ids)",
new { ids = libraryIds });
}
public Task EnablePlexLibrarySync(IEnumerable<int> libraryIds) =>
@@ -0,0 +1,78 @@
using System.Data;
using System.Threading.Tasks;
using Dapper;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using LanguageExt;
namespace ErsatzTV.Infrastructure.Data.Repositories
{
public class MetadataRepository : IMetadataRepository
{
private readonly IDbConnection _dbConnection;
public MetadataRepository(IDbConnection dbConnection) => _dbConnection = dbConnection;
public Task<Unit> RemoveGenre(Genre genre) =>
_dbConnection.ExecuteAsync("DELETE FROM Genre WHERE Id = @GenreId", new { GenreId = genre.Id }).ToUnit();
public Task<Unit> UpdateStatistics(MediaVersion mediaVersion) =>
_dbConnection.ExecuteAsync(
@"UPDATE MediaVersion SET
SampleAspectRatio = @SampleAspectRatio,
VideoScanKind = @VideoScanKind,
DateUpdated = @DateUpdated
WHERE Id = @MediaVersionId",
new
{
mediaVersion.SampleAspectRatio,
mediaVersion.VideoScanKind,
mediaVersion.DateUpdated,
MediaVersionId = mediaVersion.Id
}).ToUnit();
public Task<Unit> UpdateArtworkPath(Artwork artwork) =>
_dbConnection.ExecuteAsync(
"UPDATE Artwork SET Path = @Path, DateUpdated = @DateUpdated WHERE Id = @Id",
new { artwork.Path, artwork.DateUpdated, artwork.Id }).ToUnit();
public Task<Unit> AddArtwork(Metadata metadata, Artwork artwork)
{
var parameters = new
{
artwork.ArtworkKind, metadata.Id, artwork.DateAdded, artwork.DateUpdated, artwork.Path
};
return metadata switch
{
MovieMetadata => _dbConnection.ExecuteAsync(
@"INSERT INTO Artwork (ArtworkKind, MovieMetadataId, DateAdded, DateUpdated, Path)
VALUES (@ArtworkKind, @Id, @DateAdded, @DateUpdated, @Path)",
parameters)
.ToUnit(),
ShowMetadata => _dbConnection.ExecuteAsync(
@"INSERT INTO Artwork (ArtworkKind, ShowMetadataId, DateAdded, DateUpdated, Path)
VALUES (@ArtworkKind, @Id, @DateAdded, @DateUpdated, @Path)",
parameters)
.ToUnit(),
SeasonMetadata => _dbConnection.ExecuteAsync(
@"INSERT INTO Artwork (ArtworkKind, SeasonMetadataId, DateAdded, DateUpdated, Path)
VALUES (@ArtworkKind, @Id, @DateAdded, @DateUpdated, @Path)",
parameters)
.ToUnit(),
EpisodeMetadata => _dbConnection.ExecuteAsync(
@"INSERT INTO Artwork (ArtworkKind, EpisodeMetadataId, DateAdded, DateUpdated, Path)
VALUES (@ArtworkKind, @Id, @DateAdded, @DateUpdated, @Path)",
parameters)
.ToUnit(),
_ => Task.FromResult(Unit.Default)
};
}
public Task<Unit> RemoveArtwork(Metadata metadata, ArtworkKind artworkKind) =>
_dbConnection.ExecuteAsync(
@"DELETE FROM Artwork WHERE ArtworkKind = @ArtworkKind AND (MovieMetadataId = @Id
OR ShowMetadataId = @Id OR SeasonMetadataId = @Id OR EpisodeMetadataId = @Id)",
new { ArtworkKind = artworkKind, metadata.Id }).ToUnit();
}
}
@@ -29,12 +29,22 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
_dbConnection = dbConnection;
}
public Task<bool> AllMoviesExist(List<int> movieIds) =>
_dbConnection.QuerySingleAsync<int>(
"SELECT COUNT(*) FROM Movie WHERE Id in @MovieIds",
new { MovieIds = movieIds })
.Map(c => c == movieIds.Count);
public async Task<Option<Movie>> GetMovie(int movieId)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
return await dbContext.Movies
.Include(m => m.MovieMetadata)
.ThenInclude(m => m.Artwork)
.Include(m => m.MovieMetadata)
.ThenInclude(m => m.Genres)
.Include(m => m.MovieMetadata)
.ThenInclude(m => m.Tags)
.OrderBy(m => m.Id)
.SingleOrDefaultAsync(m => m.Id == movieId)
.Map(Optional);
@@ -45,6 +55,10 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
Option<Movie> maybeExisting = await _dbContext.Movies
.Include(i => i.MovieMetadata)
.ThenInclude(mm => mm.Artwork)
.Include(i => i.MovieMetadata)
.ThenInclude(mm => mm.Genres)
.Include(i => i.MovieMetadata)
.ThenInclude(mm => mm.Tags)
.Include(i => i.LibraryPath)
.Include(i => i.MediaVersions)
.ThenInclude(mv => mv.MediaFiles)
@@ -62,6 +76,9 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
Option<PlexMovie> maybeExisting = await context.PlexMovies
.AsNoTracking()
.Include(i => i.MovieMetadata)
.ThenInclude(mm => mm.Genres)
.Include(i => i.MovieMetadata)
.ThenInclude(mm => mm.Artwork)
.Include(i => i.MediaVersions)
.ThenInclude(mv => mv.MediaFiles)
.OrderBy(i => i.Key)
@@ -125,6 +142,20 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
return Unit.Default;
}
public Task<Unit> AddGenre(MovieMetadata metadata, Genre genre) =>
_dbConnection.ExecuteAsync(
"INSERT INTO Genre (Name, MovieMetadataId) VALUES (@Name, @MetadataId)",
new { genre.Name, MetadataId = metadata.Id }).ToUnit();
public Task<Unit> RemoveMissingPlexMovies(PlexLibrary library, List<string> movieKeys) =>
_dbConnection.ExecuteAsync(
@"DELETE FROM MediaItem WHERE Id IN
(SELECT m.Id FROM MediaItem m
INNER JOIN PlexMovie pm ON pm.Id = m.Id
INNER JOIN LibraryPath lp ON lp.Id = m.LibraryPathId
WHERE lp.LibraryId = @LibraryId AND pm.Key not in @Keys)",
new { LibraryId = library.Id, Keys = movieKeys }).ToUnit();
private async Task<Either<BaseError, Movie>> AddMovie(int libraryPathId, string path)
{
try
@@ -13,8 +13,13 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
public class PlayoutRepository : IPlayoutRepository
{
private readonly TvContext _dbContext;
private readonly IDbContextFactory<TvContext> _dbContextFactory;
public PlayoutRepository(TvContext dbContext) => _dbContext = dbContext;
public PlayoutRepository(TvContext dbContext, IDbContextFactory<TvContext> dbContextFactory)
{
_dbContext = dbContext;
_dbContextFactory = dbContextFactory;
}
public async Task<Playout> Add(Playout playout)
{
@@ -61,14 +66,17 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
public Task<Option<DateTimeOffset>> GetNextItemStart(int channelId, DateTimeOffset now) =>
_dbContext.PlayoutItems
.Where(pi => pi.Playout.ChannelId == channelId)
.Where(pi => pi.Finish > now.UtcDateTime)
.OrderBy(pi => pi.Finish)
.Where(pi => pi.Start > now.UtcDateTime)
.OrderBy(pi => pi.Start)
.FirstOrDefaultAsync()
.Map(Optional)
.MapT(pi => pi.StartOffset);
public Task<List<PlayoutItem>> GetPlayoutItems(int playoutId) =>
_dbContext.PlayoutItems
public async Task<List<PlayoutItem>> GetPlayoutItems(int playoutId)
{
await using TvContext context = _dbContextFactory.CreateDbContext();
return await context.PlayoutItems
.AsNoTracking()
.Include(i => i.MediaItem)
.ThenInclude(mi => (mi as Movie).MovieMetadata)
.ThenInclude(mm => mm.Artwork)
@@ -84,12 +92,17 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.ThenInclude(s => s.SeasonMetadata)
.Filter(i => i.PlayoutId == playoutId)
.ToListAsync();
}
public Task<List<Playout>> GetAll() =>
_dbContext.Playouts
public async Task<List<Playout>> GetAll()
{
await using TvContext context = _dbContextFactory.CreateDbContext();
return await context.Playouts
.AsNoTracking()
.Include(p => p.Channel)
.Include(p => p.ProgramSchedule)
.ToListAsync();
}
public async Task Update(Playout playout)
{
@@ -21,7 +21,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
_dbConnection = dbConnection;
}
public async Task<List<MediaItem>> SearchMediaItems(string query)
public async Task<List<MediaItem>> SearchMediaItemsByTitle(string query)
{
List<int> ids = await _dbConnection.QueryAsync<int>(
@"SELECT M.Id FROM Movie M
@@ -30,7 +30,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
UNION
SELECT S.Id FROM Show S
INNER JOIN ShowMetadata SM on S.Id = SM.ShowId
WHERE SM.Title LIKE @Query",
WHERE SM.Title LIKE @Query
GROUP BY SM.Title, SM.Year",
new { Query = $"%{query}%" })
.Map(results => results.ToList());
@@ -44,5 +45,59 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.OfType<MediaItem>()
.ToListAsync();
}
public async Task<List<MediaItem>> SearchMediaItemsByGenre(string genre)
{
List<int> ids = await _dbConnection.QueryAsync<int>(
@"SELECT M.Id FROM Movie M
INNER JOIN MovieMetadata MM on M.Id = MM.MovieId
INNER JOIN Genre G on MM.Id = G.MovieMetadataId
WHERE G.Name LIKE @Query
UNION
SELECT S.Id FROM Show S
INNER JOIN ShowMetadata SM on S.Id = SM.ShowId
INNER JOIN Genre G2 on SM.Id = G2.ShowMetadataId
WHERE G2.Name LIKE @Query
GROUP BY SM.Title, SM.Year",
new { Query = genre })
.Map(results => results.ToList());
await using TvContext context = _dbContextFactory.CreateDbContext();
return await context.MediaItems
.Filter(m => ids.Contains(m.Id))
.Include(m => (m as Movie).MovieMetadata)
.ThenInclude(mm => mm.Artwork)
.Include(m => (m as Show).ShowMetadata)
.ThenInclude(mm => mm.Artwork)
.OfType<MediaItem>()
.ToListAsync();
}
public async Task<List<MediaItem>> SearchMediaItemsByTag(string tag)
{
List<int> ids = await _dbConnection.QueryAsync<int>(
@"SELECT M.Id FROM Movie M
INNER JOIN MovieMetadata MM on M.Id = MM.MovieId
INNER JOIN Tag T on MM.Id = T.MovieMetadataId
WHERE T.Name LIKE @Query
UNION
SELECT S.Id FROM Show S
INNER JOIN ShowMetadata SM on S.Id = SM.ShowId
INNER JOIN Tag T2 on SM.Id = T2.ShowMetadataId
WHERE T2.Name LIKE @Query
GROUP BY SM.Title, SM.Year",
new { Query = tag })
.Map(results => results.ToList());
await using TvContext context = _dbContextFactory.CreateDbContext();
return await context.MediaItems
.Filter(m => ids.Contains(m.Id))
.Include(m => (m as Movie).MovieMetadata)
.ThenInclude(mm => mm.Artwork)
.Include(m => (m as Show).ShowMetadata)
.ThenInclude(mm => mm.Artwork)
.OfType<MediaItem>()
.ToListAsync();
}
}
}
@@ -17,13 +17,24 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
{
private readonly IDbConnection _dbConnection;
private readonly TvContext _dbContext;
private readonly IDbContextFactory<TvContext> _dbContextFactory;
public TelevisionRepository(TvContext dbContext, IDbConnection dbConnection)
public TelevisionRepository(
TvContext dbContext,
IDbConnection dbConnection,
IDbContextFactory<TvContext> dbContextFactory)
{
_dbContext = dbContext;
_dbConnection = dbConnection;
_dbContextFactory = dbContextFactory;
}
public Task<bool> AllShowsExist(List<int> showIds) =>
_dbConnection.QuerySingleAsync<int>(
"SELECT COUNT(*) FROM Show WHERE Id in @ShowIds",
new { ShowIds = showIds })
.Map(c => c == showIds.Count);
public async Task<bool> Update(Show show)
{
_dbContext.Shows.Update(show);
@@ -55,6 +66,10 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.Filter(s => s.Id == showId)
.Include(s => s.ShowMetadata)
.ThenInclude(sm => sm.Artwork)
.Include(s => s.ShowMetadata)
.ThenInclude(sm => sm.Genres)
.Include(s => s.ShowMetadata)
.ThenInclude(sm => sm.Tags)
.OrderBy(s => s.Id)
.SingleOrDefaultAsync()
.Map(Optional);
@@ -94,6 +109,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.ThenInclude(sm => sm.Artwork)
.Include(s => s.Show)
.ThenInclude(s => s.ShowMetadata)
.ThenInclude(sm => sm.Artwork)
.OrderBy(s => s.Id)
.SingleOrDefaultAsync(s => s.Id == seasonId)
.Map(Optional);
@@ -170,6 +186,10 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
return _dbContext.Shows
.Include(s => s.ShowMetadata)
.ThenInclude(sm => sm.Artwork)
.Include(s => s.ShowMetadata)
.ThenInclude(sm => sm.Genres)
.Include(s => s.ShowMetadata)
.ThenInclude(sm => sm.Tags)
.OrderBy(s => s.Id)
.SingleOrDefaultAsync(s => s.Id == id)
.Map(Optional);
@@ -182,6 +202,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
try
{
metadata.DateAdded = DateTime.UtcNow;
metadata.Genres ??= new List<Genre>();
metadata.Tags ??= new List<Tag>();
var show = new Show
{
LibraryPathId = libraryPathId,
@@ -288,6 +310,89 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
})
.ToUnit();
public async Task<Either<BaseError, PlexShow>> GetOrAddPlexShow(PlexLibrary library, PlexShow item)
{
await using TvContext context = _dbContextFactory.CreateDbContext();
Option<PlexShow> maybeExisting = await context.PlexShows
.AsNoTracking()
.Include(i => i.ShowMetadata)
.ThenInclude(mm => mm.Genres)
.Include(i => i.ShowMetadata)
.ThenInclude(mm => mm.Artwork)
.OrderBy(i => i.Key)
.SingleOrDefaultAsync(i => i.Key == item.Key);
return await maybeExisting.Match(
plexShow => Right<BaseError, PlexShow>(plexShow).AsTask(),
async () => await AddPlexShow(context, library, item));
}
public async Task<Either<BaseError, PlexSeason>> GetOrAddPlexSeason(PlexLibrary library, PlexSeason item)
{
await using TvContext context = _dbContextFactory.CreateDbContext();
Option<PlexSeason> maybeExisting = await context.PlexSeasons
.AsNoTracking()
.Include(i => i.SeasonMetadata)
.ThenInclude(mm => mm.Artwork)
.OrderBy(i => i.Key)
.SingleOrDefaultAsync(i => i.Key == item.Key);
return await maybeExisting.Match(
plexSeason => Right<BaseError, PlexSeason>(plexSeason).AsTask(),
async () => await AddPlexSeason(context, library, item));
}
public async Task<Either<BaseError, PlexEpisode>> GetOrAddPlexEpisode(PlexLibrary library, PlexEpisode item)
{
await using TvContext context = _dbContextFactory.CreateDbContext();
Option<PlexEpisode> maybeExisting = await context.PlexEpisodes
.AsNoTracking()
.Include(i => i.EpisodeMetadata)
.ThenInclude(mm => mm.Artwork)
.Include(i => i.MediaVersions)
.ThenInclude(mv => mv.MediaFiles)
.OrderBy(i => i.Key)
.SingleOrDefaultAsync(i => i.Key == item.Key);
return await maybeExisting.Match(
plexEpisode => Right<BaseError, PlexEpisode>(plexEpisode).AsTask(),
async () => await AddPlexEpisode(context, library, item));
}
public Task<Unit> AddGenre(ShowMetadata metadata, Genre genre) =>
_dbConnection.ExecuteAsync(
"INSERT INTO Genre (Name, SeasonMetadataId) VALUES (@Name, @MetadataId)",
new { genre.Name, MetadataId = metadata.Id }).ToUnit();
public Task<Unit> RemoveMissingPlexShows(PlexLibrary library, List<string> showKeys) =>
_dbConnection.ExecuteAsync(
@"DELETE FROM MediaItem WHERE Id IN
(SELECT m.Id FROM MediaItem m
INNER JOIN PlexShow ps ON ps.Id = m.Id
INNER JOIN LibraryPath lp ON lp.Id = m.LibraryPathId
WHERE lp.LibraryId = @LibraryId AND ps.Key not in @Keys)",
new { LibraryId = library.Id, Keys = showKeys }).ToUnit();
public Task<Unit> RemoveMissingPlexSeasons(string showKey, List<string> seasonKeys) =>
_dbConnection.ExecuteAsync(
@"DELETE FROM MediaItem WHERE Id IN
(SELECT m.Id FROM MediaItem m
INNER JOIN Season s ON m.Id = s.Id
INNER JOIN PlexSeason ps ON ps.Id = m.Id
INNER JOIN PlexShow P on P.Id = s.ShowId
WHERE P.Key = @ShowKey AND ps.Key not in @Keys)",
new { ShowKey = showKey, Keys = seasonKeys }).ToUnit();
public Task<Unit> RemoveMissingPlexEpisodes(string seasonKey, List<string> episodeKeys) =>
_dbConnection.ExecuteAsync(
@"DELETE FROM MediaItem WHERE Id IN
(SELECT m.Id FROM MediaItem m
INNER JOIN Episode e ON m.Id = e.Id
INNER JOIN PlexEpisode pe ON pe.Id = m.Id
INNER JOIN PlexSeason P on P.Id = e.SeasonId
WHERE P.Key = @SeasonKey AND pe.Key not in @Keys)",
new { SeasonKey = seasonKey, Keys = episodeKeys }).ToUnit();
public async Task<List<Episode>> GetShowItems(int showId)
{
IEnumerable<int> ids = await _dbConnection.QueryAsync<int>(
@@ -364,5 +469,65 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
return BaseError.New(ex.Message);
}
}
private async Task<Either<BaseError, PlexShow>> AddPlexShow(
TvContext context,
PlexLibrary library,
PlexShow item)
{
try
{
item.LibraryPathId = library.Paths.Head().Id;
await context.PlexShows.AddAsync(item);
await context.SaveChangesAsync();
await context.Entry(item).Reference(i => i.LibraryPath).LoadAsync();
return item;
}
catch (Exception ex)
{
return BaseError.New(ex.Message);
}
}
private async Task<Either<BaseError, PlexSeason>> AddPlexSeason(
TvContext context,
PlexLibrary library,
PlexSeason item)
{
try
{
item.LibraryPathId = library.Paths.Head().Id;
await context.PlexSeasons.AddAsync(item);
await context.SaveChangesAsync();
await context.Entry(item).Reference(i => i.LibraryPath).LoadAsync();
return item;
}
catch (Exception ex)
{
return BaseError.New(ex.Message);
}
}
private async Task<Either<BaseError, PlexEpisode>> AddPlexEpisode(
TvContext context,
PlexLibrary library,
PlexEpisode item)
{
try
{
item.LibraryPathId = library.Paths.Head().Id;
await context.PlexEpisodes.AddAsync(item);
await context.SaveChangesAsync();
await context.Entry(item).Reference(i => i.LibraryPath).LoadAsync();
return item;
}
catch (Exception ex)
{
return BaseError.New(ex.Message);
}
}
}
}
@@ -33,7 +33,11 @@ namespace ErsatzTV.Infrastructure.Data
public DbSet<Episode> Episodes { get; set; }
public DbSet<EpisodeMetadata> EpisodeMetadata { get; set; }
public DbSet<PlexMovie> PlexMovies { get; set; }
public DbSet<PlexShow> PlexShows { get; set; }
public DbSet<PlexSeason> PlexSeasons { get; set; }
public DbSet<PlexEpisode> PlexEpisodes { get; set; }
public DbSet<Collection> Collections { get; set; }
public DbSet<CollectionItem> CollectionItems { get; set; }
public DbSet<ProgramSchedule> ProgramSchedules { get; set; }
public DbSet<Playout> Playouts { get; set; }
public DbSet<PlayoutItem> PlayoutItems { get; set; }
@@ -7,14 +7,14 @@
<ItemGroup>
<PackageReference Include="Dapper" Version="2.0.78" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="5.0.3" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="5.0.3">
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="5.0.4" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="5.0.4">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="5.0.3" />
<PackageReference Include="Refit" Version="6.0.1" />
<PackageReference Include="SixLabors.ImageSharp" Version="1.0.2" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="5.0.4" />
<PackageReference Include="Refit" Version="6.0.24" />
<PackageReference Include="SixLabors.ImageSharp" Version="1.0.3" />
</ItemGroup>
<ItemGroup>
@@ -7,11 +7,14 @@ namespace ErsatzTV.Infrastructure.Locking
public class EntityLocker : IEntityLocker
{
private readonly ConcurrentDictionary<int, byte> _lockedMediaSources;
private bool _plex;
public EntityLocker() => _lockedMediaSources = new ConcurrentDictionary<int, byte>();
public event EventHandler OnLibraryChanged;
public event EventHandler OnPlexChanged;
public bool LockLibrary(int mediaSourceId)
{
if (!_lockedMediaSources.ContainsKey(mediaSourceId) && _lockedMediaSources.TryAdd(mediaSourceId, 0))
@@ -36,5 +39,31 @@ namespace ErsatzTV.Infrastructure.Locking
public bool IsLibraryLocked(int mediaSourceId) =>
_lockedMediaSources.ContainsKey(mediaSourceId);
public bool LockPlex()
{
if (!_plex)
{
_plex = true;
OnPlexChanged?.Invoke(this, EventArgs.Empty);
return true;
}
return false;
}
public bool UnlockPlex()
{
if (_plex)
{
_plex = false;
OnPlexChanged?.Invoke(this, EventArgs.Empty);
return true;
}
return false;
}
public bool IsPlexLocked() => _plex;
}
}

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