Compare commits
18
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e841c9c53b | ||
|
|
4c78f41c5a | ||
|
|
95cceb95b9 | ||
|
|
58d6f81d2e | ||
|
|
fe5cedfcdc | ||
|
|
0bbed69e85 | ||
|
|
68123a2f9c | ||
|
|
6504ca10a8 | ||
|
|
84770ed250 | ||
|
|
466d33f808 | ||
|
|
8e81d5f197 | ||
|
|
da43e6f7cf | ||
|
|
c9905d0542 | ||
|
|
c9e20e28df | ||
|
|
f9427cac99 | ||
|
|
141a34933d | ||
|
|
0962a1429a | ||
|
|
f8b45ed9db |
@@ -3,7 +3,7 @@
|
||||
"isRoot": true,
|
||||
"tools": {
|
||||
"jetbrains.resharper.globaltools": {
|
||||
"version": "2020.3.2",
|
||||
"version": "2021.1.3",
|
||||
"commands": [
|
||||
"jb"
|
||||
]
|
||||
|
||||
@@ -36,6 +36,7 @@ namespace ErsatzTV.Application.Emby.Commands
|
||||
{
|
||||
List<int> ids = await _mediaSourceRepository.DeleteAllEmby();
|
||||
await _searchIndex.RemoveItems(ids);
|
||||
_searchIndex.Commit();
|
||||
await _embySecretStore.DeleteAll();
|
||||
_entityLocker.UnlockRemoteMediaSource<EmbyMediaSource>();
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ namespace ErsatzTV.Application.Emby.Commands
|
||||
var toDisable = request.Preferences.Filter(p => p.ShouldSyncItems == false).Map(p => p.Id).ToList();
|
||||
List<int> ids = await _mediaSourceRepository.DisableEmbyLibrarySync(toDisable);
|
||||
await _searchIndex.RemoveItems(ids);
|
||||
_searchIndex.Commit();
|
||||
|
||||
IEnumerable<int> toEnable = request.Preferences.Filter(p => p.ShouldSyncItems).Map(p => p.Id);
|
||||
await _mediaSourceRepository.EnableEmbyLibrarySync(toEnable);
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
namespace ErsatzTV.Application.Emby
|
||||
{
|
||||
public record EmbyConnectionParametersViewModel(string Address);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
using ErsatzTV.Core;
|
||||
using LanguageExt;
|
||||
using MediatR;
|
||||
|
||||
namespace ErsatzTV.Application.Emby.Queries
|
||||
{
|
||||
public record GetEmbyConnectionParameters : IRequest<Either<BaseError, EmbyConnectionParametersViewModel>>;
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using LanguageExt;
|
||||
using MediatR;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
|
||||
namespace ErsatzTV.Application.Emby.Queries
|
||||
{
|
||||
public class GetEmbyConnectionParametersHandler : IRequestHandler<GetEmbyConnectionParameters,
|
||||
Either<BaseError, EmbyConnectionParametersViewModel>>
|
||||
{
|
||||
private readonly IMediaSourceRepository _mediaSourceRepository;
|
||||
private readonly IMemoryCache _memoryCache;
|
||||
|
||||
public GetEmbyConnectionParametersHandler(
|
||||
IMemoryCache memoryCache,
|
||||
IMediaSourceRepository mediaSourceRepository)
|
||||
{
|
||||
_memoryCache = memoryCache;
|
||||
_mediaSourceRepository = mediaSourceRepository;
|
||||
}
|
||||
|
||||
public async Task<Either<BaseError, EmbyConnectionParametersViewModel>> Handle(
|
||||
GetEmbyConnectionParameters request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (_memoryCache.TryGetValue(request, out EmbyConnectionParametersViewModel parameters))
|
||||
{
|
||||
return parameters;
|
||||
}
|
||||
|
||||
Either<BaseError, EmbyConnectionParametersViewModel> maybeParameters =
|
||||
await Validate()
|
||||
.MapT(cp => new EmbyConnectionParametersViewModel(cp.ActiveConnection.Address))
|
||||
.Map(v => v.ToEither<EmbyConnectionParametersViewModel>());
|
||||
|
||||
return maybeParameters.Match(
|
||||
p =>
|
||||
{
|
||||
_memoryCache.Set(request, p, TimeSpan.FromHours(1));
|
||||
return maybeParameters;
|
||||
},
|
||||
error => error);
|
||||
}
|
||||
|
||||
private Task<Validation<BaseError, ConnectionParameters>> Validate() =>
|
||||
EmbyMediaSourceMustExist()
|
||||
.BindT(MediaSourceMustHaveActiveConnection);
|
||||
|
||||
private Task<Validation<BaseError, EmbyMediaSource>> EmbyMediaSourceMustExist() =>
|
||||
_mediaSourceRepository.GetAllEmby().Map(list => list.HeadOrNone())
|
||||
.Map(
|
||||
v => v.ToValidation<BaseError>(
|
||||
"Emby media source does not exist."));
|
||||
|
||||
private Validation<BaseError, ConnectionParameters> MediaSourceMustHaveActiveConnection(
|
||||
EmbyMediaSource embyMediaSource)
|
||||
{
|
||||
Option<EmbyConnection> maybeConnection = embyMediaSource.Connections.FirstOrDefault();
|
||||
return maybeConnection.Map(connection => new ConnectionParameters(embyMediaSource, connection))
|
||||
.ToValidation<BaseError>("Emby media source requires an active connection");
|
||||
}
|
||||
|
||||
private record ConnectionParameters(
|
||||
EmbyMediaSource EmbyMediaSource,
|
||||
EmbyConnection ActiveConnection);
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net5.0</TargetFramework>
|
||||
<NoWarn>VSTHRD200</NoWarn>
|
||||
<DebugType>embedded</DebugType>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Interfaces.Runtime;
|
||||
using LanguageExt;
|
||||
|
||||
namespace ErsatzTV.Application.FFmpegProfiles.Commands
|
||||
@@ -14,13 +16,16 @@ namespace ErsatzTV.Application.FFmpegProfiles.Commands
|
||||
{
|
||||
private readonly IConfigElementRepository _configElementRepository;
|
||||
private readonly ILocalFileSystem _localFileSystem;
|
||||
private readonly IRuntimeInfo _runtimeInfo;
|
||||
|
||||
public UpdateFFmpegSettingsHandler(
|
||||
IConfigElementRepository configElementRepository,
|
||||
ILocalFileSystem localFileSystem)
|
||||
ILocalFileSystem localFileSystem,
|
||||
IRuntimeInfo runtimeInfo)
|
||||
{
|
||||
_configElementRepository = configElementRepository;
|
||||
_localFileSystem = localFileSystem;
|
||||
_runtimeInfo = runtimeInfo;
|
||||
}
|
||||
|
||||
public Task<Either<BaseError, Unit>> Handle(
|
||||
@@ -31,8 +36,8 @@ namespace ErsatzTV.Application.FFmpegProfiles.Commands
|
||||
.Bind(v => v.ToEitherAsync());
|
||||
|
||||
private async Task<Validation<BaseError, Unit>> Validate(UpdateFFmpegSettings request) =>
|
||||
(await FFmpegMustExist(request), await FFprobeMustExist(request))
|
||||
.Apply((_, _) => Unit.Default);
|
||||
(await FFmpegMustExist(request), await FFprobeMustExist(request), ReportsAreNotSupportedOnWindows(request))
|
||||
.Apply((_, _, _) => Unit.Default);
|
||||
|
||||
private Task<Validation<BaseError, Unit>> FFmpegMustExist(UpdateFFmpegSettings request) =>
|
||||
ValidateToolPath(request.Settings.FFmpegPath, "ffmpeg");
|
||||
@@ -40,6 +45,16 @@ namespace ErsatzTV.Application.FFmpegProfiles.Commands
|
||||
private Task<Validation<BaseError, Unit>> FFprobeMustExist(UpdateFFmpegSettings request) =>
|
||||
ValidateToolPath(request.Settings.FFprobePath, "ffprobe");
|
||||
|
||||
private Validation<BaseError, Unit> ReportsAreNotSupportedOnWindows(UpdateFFmpegSettings request)
|
||||
{
|
||||
if (request.Settings.SaveReports && _runtimeInfo.IsOSPlatform(OSPlatform.Windows))
|
||||
{
|
||||
return BaseError.New("FFmpeg reports are not supported on Windows");
|
||||
}
|
||||
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
private async Task<Validation<BaseError, Unit>> ValidateToolPath(string path, string name)
|
||||
{
|
||||
if (!_localFileSystem.FileExists(path))
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
namespace ErsatzTV.Application.Images
|
||||
{
|
||||
public record CachedImagePathViewModel(string FileName, string MimeType);
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
namespace ErsatzTV.Application.Images
|
||||
{
|
||||
// ReSharper disable once SuggestBaseTypeForParameter
|
||||
public record ImageViewModel(byte[] Contents, string MimeType);
|
||||
}
|
||||
+3
-2
@@ -5,6 +5,7 @@ using MediatR;
|
||||
|
||||
namespace ErsatzTV.Application.Images.Queries
|
||||
{
|
||||
public record GetImageContents
|
||||
(string FileName, ArtworkKind ArtworkKind, int? MaxHeight = null) : IRequest<Either<BaseError, ImageViewModel>>;
|
||||
public record GetCachedImagePath
|
||||
(string FileName, ArtworkKind ArtworkKind, int? MaxHeight = null) : IRequest<
|
||||
Either<BaseError, CachedImagePathViewModel>>;
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Interfaces.Images;
|
||||
using LanguageExt;
|
||||
using MediatR;
|
||||
using Winista.Mime;
|
||||
using static LanguageExt.Prelude;
|
||||
|
||||
namespace ErsatzTV.Application.Images.Queries
|
||||
{
|
||||
public class
|
||||
GetCachedImagePathHandler : IRequestHandler<GetCachedImagePath, Either<BaseError, CachedImagePathViewModel>>
|
||||
{
|
||||
private static readonly MimeTypes MimeTypes = new();
|
||||
private readonly IImageCache _imageCache;
|
||||
|
||||
public GetCachedImagePathHandler(IImageCache imageCache) => _imageCache = imageCache;
|
||||
|
||||
public async Task<Either<BaseError, CachedImagePathViewModel>> Handle(
|
||||
GetCachedImagePath request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
MimeType mimeType;
|
||||
|
||||
string cachePath = _imageCache.GetPathForImage(
|
||||
request.FileName,
|
||||
request.ArtworkKind,
|
||||
Optional(request.MaxHeight));
|
||||
if (!File.Exists(cachePath))
|
||||
{
|
||||
if (request.MaxHeight.HasValue)
|
||||
{
|
||||
string originalPath = _imageCache.GetPathForImage(request.FileName, request.ArtworkKind, None);
|
||||
byte[] contents = await File.ReadAllBytesAsync(originalPath, cancellationToken);
|
||||
Either<BaseError, byte[]> resizeResult =
|
||||
await _imageCache.ResizeImage(contents, request.MaxHeight.Value);
|
||||
resizeResult.IfRight(result => contents = result);
|
||||
|
||||
string baseFolder = Path.GetDirectoryName(cachePath);
|
||||
if (baseFolder != null && !Directory.Exists(baseFolder))
|
||||
{
|
||||
Directory.CreateDirectory(baseFolder);
|
||||
}
|
||||
|
||||
await File.WriteAllBytesAsync(cachePath, contents, cancellationToken);
|
||||
|
||||
mimeType = new MimeType("image/jpeg");
|
||||
}
|
||||
else
|
||||
{
|
||||
return BaseError.New($"Artwork does not exist on disk at {cachePath}");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
mimeType = MimeTypes.GetMimeTypeFromFile(cachePath);
|
||||
}
|
||||
|
||||
return new CachedImagePathViewModel(cachePath, mimeType.Name);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return BaseError.New(ex.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Images;
|
||||
using LanguageExt;
|
||||
using MediatR;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Winista.Mime;
|
||||
|
||||
namespace ErsatzTV.Application.Images.Queries
|
||||
{
|
||||
public class GetImageContentsHandler : IRequestHandler<GetImageContents, Either<BaseError, ImageViewModel>>
|
||||
{
|
||||
private static readonly MimeTypes MimeTypes = new();
|
||||
private readonly IImageCache _imageCache;
|
||||
private readonly IMemoryCache _memoryCache;
|
||||
|
||||
public GetImageContentsHandler(IImageCache imageCache, IMemoryCache memoryCache)
|
||||
{
|
||||
_imageCache = imageCache;
|
||||
_memoryCache = memoryCache;
|
||||
}
|
||||
|
||||
public async Task<Either<BaseError, ImageViewModel>> Handle(
|
||||
GetImageContents request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await _memoryCache.GetOrCreateAsync(
|
||||
request.FileName,
|
||||
async entry =>
|
||||
{
|
||||
entry.SlidingExpiration = TimeSpan.FromHours(1);
|
||||
|
||||
string subfolder = request.FileName.Substring(0, 2);
|
||||
string baseFolder = request.ArtworkKind switch
|
||||
{
|
||||
ArtworkKind.Poster => Path.Combine(FileSystemLayout.PosterCacheFolder, subfolder),
|
||||
ArtworkKind.Thumbnail => Path.Combine(FileSystemLayout.ThumbnailCacheFolder, subfolder),
|
||||
ArtworkKind.Logo => Path.Combine(FileSystemLayout.LogoCacheFolder, subfolder),
|
||||
ArtworkKind.FanArt => Path.Combine(FileSystemLayout.FanArtCacheFolder, subfolder),
|
||||
_ => FileSystemLayout.LegacyImageCacheFolder
|
||||
};
|
||||
|
||||
string fileName = Path.Combine(baseFolder, request.FileName);
|
||||
byte[] contents = await File.ReadAllBytesAsync(fileName, cancellationToken);
|
||||
|
||||
if (request.MaxHeight.HasValue)
|
||||
{
|
||||
Either<BaseError, byte[]> resizeResult = await _imageCache
|
||||
.ResizeImage(contents, request.MaxHeight.Value);
|
||||
resizeResult.IfRight(result => contents = result);
|
||||
}
|
||||
|
||||
MimeType mimeType = MimeTypes.GetMimeType(contents);
|
||||
return new ImageViewModel(contents, mimeType.Name);
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return BaseError.New(ex.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,7 @@ namespace ErsatzTV.Application.Jellyfin.Commands
|
||||
{
|
||||
List<int> ids = await _mediaSourceRepository.DeleteAllJellyfin();
|
||||
await _searchIndex.RemoveItems(ids);
|
||||
_searchIndex.Commit();
|
||||
await _jellyfinSecretStore.DeleteAll();
|
||||
_entityLocker.UnlockRemoteMediaSource<JellyfinMediaSource>();
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ namespace ErsatzTV.Application.Jellyfin.Commands
|
||||
var toDisable = request.Preferences.Filter(p => p.ShouldSyncItems == false).Map(p => p.Id).ToList();
|
||||
List<int> ids = await _mediaSourceRepository.DisableJellyfinLibrarySync(toDisable);
|
||||
await _searchIndex.RemoveItems(ids);
|
||||
_searchIndex.Commit();
|
||||
|
||||
IEnumerable<int> toEnable = request.Preferences.Filter(p => p.ShouldSyncItems).Map(p => p.Id);
|
||||
await _mediaSourceRepository.EnableJellyfinLibrarySync(toEnable);
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
namespace ErsatzTV.Application.Jellyfin
|
||||
{
|
||||
public record JellyfinConnectionParametersViewModel(string Address);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
using ErsatzTV.Core;
|
||||
using LanguageExt;
|
||||
using MediatR;
|
||||
|
||||
namespace ErsatzTV.Application.Jellyfin.Queries
|
||||
{
|
||||
public record GetJellyfinConnectionParameters : IRequest<Either<BaseError, JellyfinConnectionParametersViewModel>>;
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using LanguageExt;
|
||||
using MediatR;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
|
||||
namespace ErsatzTV.Application.Jellyfin.Queries
|
||||
{
|
||||
public class GetJellyfinConnectionParametersHandler : IRequestHandler<GetJellyfinConnectionParameters,
|
||||
Either<BaseError, JellyfinConnectionParametersViewModel>>
|
||||
{
|
||||
private readonly IMediaSourceRepository _mediaSourceRepository;
|
||||
private readonly IMemoryCache _memoryCache;
|
||||
|
||||
public GetJellyfinConnectionParametersHandler(
|
||||
IMemoryCache memoryCache,
|
||||
IMediaSourceRepository mediaSourceRepository)
|
||||
{
|
||||
_memoryCache = memoryCache;
|
||||
_mediaSourceRepository = mediaSourceRepository;
|
||||
}
|
||||
|
||||
public async Task<Either<BaseError, JellyfinConnectionParametersViewModel>> Handle(
|
||||
GetJellyfinConnectionParameters request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (_memoryCache.TryGetValue(request, out JellyfinConnectionParametersViewModel parameters))
|
||||
{
|
||||
return parameters;
|
||||
}
|
||||
|
||||
Either<BaseError, JellyfinConnectionParametersViewModel> maybeParameters =
|
||||
await Validate()
|
||||
.MapT(cp => new JellyfinConnectionParametersViewModel(cp.ActiveConnection.Address))
|
||||
.Map(v => v.ToEither<JellyfinConnectionParametersViewModel>());
|
||||
|
||||
return maybeParameters.Match(
|
||||
p =>
|
||||
{
|
||||
_memoryCache.Set(request, p, TimeSpan.FromHours(1));
|
||||
return maybeParameters;
|
||||
},
|
||||
error => error);
|
||||
}
|
||||
|
||||
private Task<Validation<BaseError, ConnectionParameters>> Validate() =>
|
||||
JellyfinMediaSourceMustExist()
|
||||
.BindT(MediaSourceMustHaveActiveConnection);
|
||||
|
||||
private Task<Validation<BaseError, JellyfinMediaSource>> JellyfinMediaSourceMustExist() =>
|
||||
_mediaSourceRepository.GetAllJellyfin().Map(list => list.HeadOrNone())
|
||||
.Map(
|
||||
v => v.ToValidation<BaseError>(
|
||||
"Jellyfin media source does not exist."));
|
||||
|
||||
private Validation<BaseError, ConnectionParameters> MediaSourceMustHaveActiveConnection(
|
||||
JellyfinMediaSource jellyfinMediaSource)
|
||||
{
|
||||
Option<JellyfinConnection> maybeConnection = jellyfinMediaSource.Connections.FirstOrDefault();
|
||||
return maybeConnection.Map(connection => new ConnectionParameters(jellyfinMediaSource, connection))
|
||||
.ToValidation<BaseError>("Jellyfin media source requires an active connection");
|
||||
}
|
||||
|
||||
private record ConnectionParameters(
|
||||
JellyfinMediaSource JellyfinMediaSource,
|
||||
JellyfinConnection ActiveConnection);
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,7 @@ namespace ErsatzTV.Application.Libraries.Commands
|
||||
{
|
||||
List<int> ids = await _libraryRepository.GetMediaIdsByLocalPath(libraryPath.Id);
|
||||
await _searchIndex.RemoveItems(ids);
|
||||
_searchIndex.Commit();
|
||||
await _libraryRepository.DeleteLocalPath(libraryPath.Id);
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
using ErsatzTV.Core;
|
||||
using LanguageExt;
|
||||
|
||||
namespace ErsatzTV.Application.Maintenance.Commands
|
||||
{
|
||||
public record DeleteOrphanedArtwork : MediatR.IRequest<Either<BaseError, Unit>>, IBackgroundServiceRequest;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using LanguageExt;
|
||||
using static LanguageExt.Prelude;
|
||||
|
||||
namespace ErsatzTV.Application.Maintenance.Commands
|
||||
{
|
||||
public class DeleteOrphanedArtworkHandler : MediatR.IRequestHandler<DeleteOrphanedArtwork, Either<BaseError, Unit>>
|
||||
{
|
||||
private readonly IArtworkRepository _artworkRepository;
|
||||
|
||||
public DeleteOrphanedArtworkHandler(IArtworkRepository artworkRepository) =>
|
||||
_artworkRepository = artworkRepository;
|
||||
|
||||
public Task<Either<BaseError, Unit>>
|
||||
Handle(DeleteOrphanedArtwork request, CancellationToken cancellationToken) =>
|
||||
_artworkRepository.GetOrphanedArtwork()
|
||||
.Bind(_artworkRepository.Delete)
|
||||
.Map(_ => Right<BaseError, Unit>(Unit.Default));
|
||||
}
|
||||
}
|
||||
@@ -122,7 +122,7 @@ namespace ErsatzTV.Application.MediaCards
|
||||
else if (maybeEmby.IsSome && artwork.StartsWith("emby://"))
|
||||
{
|
||||
artwork = EmbyUrl.ForArtwork(maybeEmby, artwork)
|
||||
.SetQueryParam("fillHeight", 440);
|
||||
.SetQueryParam("maxHeight", 440);
|
||||
}
|
||||
|
||||
return new ActorCardViewModel(actor.Id, actor.Name, actor.Role, artwork);
|
||||
@@ -152,7 +152,7 @@ namespace ErsatzTV.Application.MediaCards
|
||||
else if (maybeEmby.IsSome && poster.StartsWith("emby://"))
|
||||
{
|
||||
poster = EmbyUrl.ForArtwork(maybeEmby, poster)
|
||||
.SetQueryParam("fillHeight", 440);
|
||||
.SetQueryParam("maxHeight", 440);
|
||||
}
|
||||
|
||||
return poster;
|
||||
@@ -174,7 +174,7 @@ namespace ErsatzTV.Application.MediaCards
|
||||
else if (maybeEmby.IsSome && thumb.StartsWith("emby://"))
|
||||
{
|
||||
thumb = EmbyUrl.ForArtwork(maybeEmby, thumb)
|
||||
.SetQueryParam("fillHeight", 220);
|
||||
.SetQueryParam("maxHeight", 220);
|
||||
}
|
||||
|
||||
return thumb;
|
||||
|
||||
@@ -26,10 +26,14 @@ namespace ErsatzTV.Application.Movies
|
||||
metadata.Genres.Map(g => g.Name).ToList(),
|
||||
metadata.Tags.Map(t => t.Name).ToList(),
|
||||
metadata.Studios.Map(s => s.Name).ToList(),
|
||||
(metadata.ContentRating ?? string.Empty).Split("/").Map(s => s.Trim())
|
||||
.Where(x => !string.IsNullOrWhiteSpace(x)).ToList(),
|
||||
LanguagesForMovie(movie),
|
||||
metadata.Actors.OrderBy(a => a.Order).ThenBy(a => a.Id)
|
||||
.Map(a => MediaCards.Mapper.ProjectToViewModel(a, maybeJellyfin, maybeEmby))
|
||||
.ToList())
|
||||
.ToList(),
|
||||
metadata.Directors.Map(d => d.Name).ToList(),
|
||||
metadata.Writers.Map(w => w.Name).ToList())
|
||||
{
|
||||
Poster = Artwork(metadata, ArtworkKind.Poster, maybeJellyfin, maybeEmby),
|
||||
FanArt = Artwork(metadata, ArtworkKind.FanArt, maybeJellyfin, maybeEmby)
|
||||
@@ -76,7 +80,7 @@ namespace ErsatzTV.Application.Movies
|
||||
Url url = EmbyUrl.ForArtwork(maybeEmby, artwork);
|
||||
if (artworkKind is ArtworkKind.Poster or ArtworkKind.Thumbnail)
|
||||
{
|
||||
url.SetQueryParam("fillHeight", 440);
|
||||
url.SetQueryParam("maxHeight", 440);
|
||||
}
|
||||
|
||||
artwork = url;
|
||||
|
||||
@@ -11,8 +11,11 @@ namespace ErsatzTV.Application.Movies
|
||||
List<string> Genres,
|
||||
List<string> Tags,
|
||||
List<string> Studios,
|
||||
List<string> ContentRatings,
|
||||
List<CultureInfo> Languages,
|
||||
List<ActorCardViewModel> Actors)
|
||||
List<ActorCardViewModel> Actors,
|
||||
List<string> Directors,
|
||||
List<string> Writers)
|
||||
{
|
||||
public string Poster { get; set; }
|
||||
public string FanArt { get; set; }
|
||||
|
||||
@@ -33,6 +33,7 @@ namespace ErsatzTV.Application.Plex.Commands
|
||||
{
|
||||
List<int> ids = await _mediaSourceRepository.DeleteAllPlex();
|
||||
await _searchIndex.RemoveItems(ids);
|
||||
_searchIndex.Commit();
|
||||
await _plexSecretStore.DeleteAll();
|
||||
_entityLocker.UnlockPlex();
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ namespace ErsatzTV.Application.Plex.Commands
|
||||
var toDisable = request.Preferences.Filter(p => p.ShouldSyncItems == false).Map(p => p.Id).ToList();
|
||||
List<int> ids = await _mediaSourceRepository.DisablePlexLibrarySync(toDisable);
|
||||
await _searchIndex.RemoveItems(ids);
|
||||
_searchIndex.Commit();
|
||||
|
||||
IEnumerable<int> toEnable = request.Preferences.Filter(p => p.ShouldSyncItems).Map(p => p.Id);
|
||||
await _mediaSourceRepository.EnablePlexLibrarySync(toEnable);
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
using System.Diagnostics;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.FFmpeg;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Interfaces.Runtime;
|
||||
using LanguageExt;
|
||||
|
||||
namespace ErsatzTV.Application.Streaming.Queries
|
||||
@@ -12,15 +14,18 @@ namespace ErsatzTV.Application.Streaming.Queries
|
||||
{
|
||||
private readonly IConfigElementRepository _configElementRepository;
|
||||
private readonly FFmpegProcessService _ffmpegProcessService;
|
||||
private readonly IRuntimeInfo _runtimeInfo;
|
||||
|
||||
public GetConcatProcessByChannelNumberHandler(
|
||||
IChannelRepository channelRepository,
|
||||
IConfigElementRepository configElementRepository,
|
||||
FFmpegProcessService ffmpegProcessService)
|
||||
FFmpegProcessService ffmpegProcessService,
|
||||
IRuntimeInfo runtimeInfo)
|
||||
: base(channelRepository, configElementRepository)
|
||||
{
|
||||
_configElementRepository = configElementRepository;
|
||||
_ffmpegProcessService = ffmpegProcessService;
|
||||
_runtimeInfo = runtimeInfo;
|
||||
}
|
||||
|
||||
protected override async Task<Either<BaseError, Process>> GetProcess(
|
||||
@@ -28,7 +33,8 @@ namespace ErsatzTV.Application.Streaming.Queries
|
||||
Channel channel,
|
||||
string ffmpegPath)
|
||||
{
|
||||
bool saveReports = await _configElementRepository.GetValue<bool>(ConfigElementKey.FFmpegSaveReports)
|
||||
bool saveReports = !_runtimeInfo.IsOSPlatform(OSPlatform.Windows) && await _configElementRepository
|
||||
.GetValue<bool>(ConfigElementKey.FFmpegSaveReports)
|
||||
.Map(result => result.IfNone(false));
|
||||
|
||||
return _ffmpegProcessService.ConcatChannel(
|
||||
|
||||
+18
-3
@@ -1,14 +1,17 @@
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Core.FFmpeg;
|
||||
using ErsatzTV.Core.Interfaces.Emby;
|
||||
using ErsatzTV.Core.Interfaces.Jellyfin;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Plex;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Interfaces.Runtime;
|
||||
using LanguageExt;
|
||||
using static LanguageExt.Prelude;
|
||||
|
||||
@@ -18,11 +21,13 @@ namespace ErsatzTV.Application.Streaming.Queries
|
||||
GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<GetPlayoutItemProcessByChannelNumber>
|
||||
{
|
||||
private readonly IConfigElementRepository _configElementRepository;
|
||||
private readonly IEmbyPathReplacementService _embyPathReplacementService;
|
||||
private readonly FFmpegProcessService _ffmpegProcessService;
|
||||
private readonly IJellyfinPathReplacementService _jellyfinPathReplacementService;
|
||||
private readonly ILocalFileSystem _localFileSystem;
|
||||
private readonly IPlayoutRepository _playoutRepository;
|
||||
private readonly IPlexPathReplacementService _plexPathReplacementService;
|
||||
private readonly IRuntimeInfo _runtimeInfo;
|
||||
|
||||
public GetPlayoutItemProcessByChannelNumberHandler(
|
||||
IChannelRepository channelRepository,
|
||||
@@ -31,7 +36,9 @@ namespace ErsatzTV.Application.Streaming.Queries
|
||||
FFmpegProcessService ffmpegProcessService,
|
||||
ILocalFileSystem localFileSystem,
|
||||
IPlexPathReplacementService plexPathReplacementService,
|
||||
IJellyfinPathReplacementService jellyfinPathReplacementService)
|
||||
IJellyfinPathReplacementService jellyfinPathReplacementService,
|
||||
IEmbyPathReplacementService embyPathReplacementService,
|
||||
IRuntimeInfo runtimeInfo)
|
||||
: base(channelRepository, configElementRepository)
|
||||
{
|
||||
_configElementRepository = configElementRepository;
|
||||
@@ -40,6 +47,8 @@ namespace ErsatzTV.Application.Streaming.Queries
|
||||
_localFileSystem = localFileSystem;
|
||||
_plexPathReplacementService = plexPathReplacementService;
|
||||
_jellyfinPathReplacementService = jellyfinPathReplacementService;
|
||||
_embyPathReplacementService = embyPathReplacementService;
|
||||
_runtimeInfo = runtimeInfo;
|
||||
}
|
||||
|
||||
protected override async Task<Either<BaseError, Process>> GetProcess(
|
||||
@@ -64,7 +73,8 @@ namespace ErsatzTV.Application.Streaming.Queries
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(playoutItemWithPath))
|
||||
};
|
||||
|
||||
bool saveReports = await _configElementRepository.GetValue<bool>(ConfigElementKey.FFmpegSaveReports)
|
||||
bool saveReports = !_runtimeInfo.IsOSPlatform(OSPlatform.Windows) && await _configElementRepository
|
||||
.GetValue<bool>(ConfigElementKey.FFmpegSaveReports)
|
||||
.Map(result => result.IfNone(false));
|
||||
|
||||
return Right<BaseError, Process>(
|
||||
@@ -143,7 +153,6 @@ namespace ErsatzTV.Application.Streaming.Queries
|
||||
{
|
||||
string path = await GetPlayoutItemPath(playoutItem);
|
||||
|
||||
// TODO: this won't work with url streaming from plex
|
||||
if (_localFileSystem.FileExists(path))
|
||||
{
|
||||
return new PlayoutItemWithPath(playoutItem, path);
|
||||
@@ -178,6 +187,12 @@ namespace ErsatzTV.Application.Streaming.Queries
|
||||
JellyfinEpisode jellyfinEpisode => await _jellyfinPathReplacementService.GetReplacementJellyfinPath(
|
||||
jellyfinEpisode.LibraryPathId,
|
||||
path),
|
||||
EmbyMovie embyMovie => await _embyPathReplacementService.GetReplacementEmbyPath(
|
||||
embyMovie.LibraryPathId,
|
||||
path),
|
||||
EmbyEpisode embyEpisode => await _embyPathReplacementService.GetReplacementEmbyPath(
|
||||
embyEpisode.LibraryPathId,
|
||||
path),
|
||||
_ => path
|
||||
};
|
||||
}
|
||||
|
||||
@@ -30,6 +30,10 @@ namespace ErsatzTV.Application.Television
|
||||
show.ShowMetadata.HeadOrNone().Map(m => m.Tags.Map(g => g.Name).ToList()).IfNone(new List<string>()),
|
||||
show.ShowMetadata.HeadOrNone().Map(m => m.Studios.Map(s => s.Name).ToList())
|
||||
.IfNone(new List<string>()),
|
||||
show.ShowMetadata.HeadOrNone()
|
||||
.Map(
|
||||
m => (m.ContentRating ?? string.Empty).Split("/").Map(s => s.Trim())
|
||||
.Where(x => !string.IsNullOrWhiteSpace(x)).ToList()).IfNone(new List<string>()),
|
||||
LanguagesForShow(languages),
|
||||
show.ShowMetadata.HeadOrNone()
|
||||
.Map(
|
||||
@@ -104,7 +108,7 @@ namespace ErsatzTV.Application.Television
|
||||
Url url = EmbyUrl.ForArtwork(maybeEmby, artwork);
|
||||
if (artworkKind == ArtworkKind.Poster)
|
||||
{
|
||||
url.SetQueryParam("fillHeight", 440);
|
||||
url.SetQueryParam("maxHeight", 440);
|
||||
}
|
||||
|
||||
artwork = url;
|
||||
|
||||
@@ -14,6 +14,7 @@ namespace ErsatzTV.Application.Television
|
||||
List<string> Genres,
|
||||
List<string> Tags,
|
||||
List<string> Studios,
|
||||
List<string> ContentRatings,
|
||||
List<CultureInfo> Languages,
|
||||
List<ActorCardViewModel> Actors);
|
||||
}
|
||||
|
||||
@@ -467,6 +467,36 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
actual.VideoCodec.Should().Be("copy");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void
|
||||
Should_SetCorrectVideoCodec_When_ContentIsCorrectSize_And_CorrectCodec_And_Framerate_ForTransportStream()
|
||||
{
|
||||
var ffmpegProfile = new FFmpegProfile
|
||||
{
|
||||
NormalizeVideo = true,
|
||||
Resolution = new Resolution { Width = 1920, Height = 1080 },
|
||||
VideoCodec = "libx264",
|
||||
FrameRate = "24"
|
||||
};
|
||||
|
||||
// not anamorphic
|
||||
var version = new MediaVersion
|
||||
{ Width = 1920, Height = 1080, SampleAspectRatio = "1:1" };
|
||||
|
||||
FFmpegPlaybackSettings actual = _calculator.CalculateSettings(
|
||||
StreamingMode.TransportStream,
|
||||
ffmpegProfile,
|
||||
version,
|
||||
new MediaStream { Codec = "libx264" },
|
||||
new MediaStream(),
|
||||
DateTimeOffset.Now,
|
||||
DateTimeOffset.Now);
|
||||
|
||||
actual.ScaledSize.IsNone.Should().BeTrue();
|
||||
actual.PadToDesiredResolution.Should().BeFalse();
|
||||
actual.VideoCodec.Should().Be("libx264");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void
|
||||
Should_SetCopyVideoCodec_When_ContentIsCorrectSize_And_NotNormalizingVideo_ForTransportStream()
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace ErsatzTV.Core.Domain
|
||||
{
|
||||
public class Director
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string Name { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,6 @@
|
||||
{
|
||||
Fallback = 0,
|
||||
Sidecar = 1,
|
||||
External
|
||||
External = 2
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
namespace ErsatzTV.Core.Domain
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace ErsatzTV.Core.Domain
|
||||
{
|
||||
public class MovieMetadata : Metadata
|
||||
{
|
||||
public string ContentRating { get; set; }
|
||||
public string Outline { get; set; }
|
||||
public string Plot { get; set; }
|
||||
public string Tagline { get; set; }
|
||||
public int MovieId { get; set; }
|
||||
public Movie Movie { get; set; }
|
||||
public List<Director> Directors { get; set; }
|
||||
public List<Writer> Writers { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
{
|
||||
public class ShowMetadata : Metadata
|
||||
{
|
||||
public string ContentRating { get; set; }
|
||||
public string Outline { get; set; }
|
||||
public string Plot { get; set; }
|
||||
public string Tagline { get; set; }
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace ErsatzTV.Core.Domain
|
||||
{
|
||||
public class Writer
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string Name { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Collections.Generic;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Threading.Tasks;
|
||||
@@ -44,7 +45,7 @@ namespace ErsatzTV.Core.Emby
|
||||
.SingleOrDefault(
|
||||
r =>
|
||||
{
|
||||
string separatorChar = IsWindows(r.EmbyMediaSource) ? @"\" : @"/";
|
||||
string separatorChar = IsWindows(r.EmbyMediaSource, path) ? @"\" : @"/";
|
||||
string prefix = r.EmbyPath.EndsWith(separatorChar)
|
||||
? r.EmbyPath
|
||||
: r.EmbyPath + separatorChar;
|
||||
@@ -55,11 +56,11 @@ namespace ErsatzTV.Core.Emby
|
||||
replacement =>
|
||||
{
|
||||
string finalPath = path.Replace(replacement.EmbyPath, replacement.LocalPath);
|
||||
if (IsWindows(replacement.EmbyMediaSource) && !_runtimeInfo.IsOSPlatform(OSPlatform.Windows))
|
||||
if (IsWindows(replacement.EmbyMediaSource, path) && !_runtimeInfo.IsOSPlatform(OSPlatform.Windows))
|
||||
{
|
||||
finalPath = finalPath.Replace(@"\", @"/");
|
||||
}
|
||||
else if (!IsWindows(replacement.EmbyMediaSource) &&
|
||||
else if (!IsWindows(replacement.EmbyMediaSource, path) &&
|
||||
_runtimeInfo.IsOSPlatform(OSPlatform.Windows))
|
||||
{
|
||||
finalPath = finalPath.Replace(@"/", @"\");
|
||||
@@ -79,7 +80,10 @@ namespace ErsatzTV.Core.Emby
|
||||
() => path);
|
||||
}
|
||||
|
||||
private static bool IsWindows(EmbyMediaSource embyMediaSource) =>
|
||||
embyMediaSource.OperatingSystem.ToLowerInvariant().StartsWith("windows");
|
||||
private static bool IsWindows(EmbyMediaSource embyMediaSource, string path)
|
||||
{
|
||||
bool isUnc = Uri.TryCreate(path, UriKind.Absolute, out Uri uri) && uri.IsUnc;
|
||||
return isUnc || embyMediaSource.OperatingSystem.ToLowerInvariant().StartsWith("windows");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,11 +21,47 @@ namespace ErsatzTV.Core.Emby
|
||||
string pathSegment = split[0];
|
||||
QueryParamCollection query = Url.ParseQueryParams(split[1]);
|
||||
|
||||
Url x = Url.Parse(address)
|
||||
return Url.Parse(address)
|
||||
.AppendPathSegment(pathSegment)
|
||||
.SetQueryParams(query);
|
||||
}
|
||||
|
||||
return x;
|
||||
public static Url ForArtwork(string address, string artwork)
|
||||
{
|
||||
string[] split = artwork.Replace("emby://", string.Empty).Split('?');
|
||||
if (split.Length != 2)
|
||||
{
|
||||
return artwork;
|
||||
}
|
||||
|
||||
string pathSegment = split[0];
|
||||
QueryParamCollection query = Url.ParseQueryParams(split[1]);
|
||||
|
||||
return Url.Parse(address)
|
||||
.AppendPathSegment(pathSegment)
|
||||
.SetQueryParams(query);
|
||||
}
|
||||
|
||||
public static Url ProxyForArtwork(string scheme, string host, string artwork, ArtworkKind artworkKind)
|
||||
{
|
||||
string[] split = artwork.Replace("emby://", string.Empty).Split('?');
|
||||
if (split.Length != 2)
|
||||
{
|
||||
return artwork;
|
||||
}
|
||||
|
||||
string pathSegment = split[0];
|
||||
QueryParamCollection query = Url.ParseQueryParams(split[1]);
|
||||
|
||||
string artworkFolder = artworkKind switch
|
||||
{
|
||||
ArtworkKind.Thumbnail => "thumbnails",
|
||||
_ => "posters"
|
||||
};
|
||||
|
||||
return Url.Parse($"{scheme}://{host}/iptv/artwork/{artworkFolder}/emby")
|
||||
.AppendPathSegment(pathSegment)
|
||||
.SetQueryParams(query);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net5.0</TargetFramework>
|
||||
<NoWarn>VSTHRD200</NoWarn>
|
||||
<DebugType>embedded</DebugType>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -99,7 +99,7 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
}
|
||||
|
||||
if (result.ScaledSize.IsSome || result.PadToDesiredResolution ||
|
||||
NeedToNormalizeVideoCodec(ffmpegProfile, videoStream))
|
||||
NeedToNormalizeVideoCodec(ffmpegProfile, videoStream) || result.FrameRate.IsSome)
|
||||
{
|
||||
result.VideoCodec = ffmpegProfile.VideoCodec;
|
||||
result.VideoBitrate = ffmpegProfile.VideoBitrate;
|
||||
|
||||
@@ -225,7 +225,8 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
|
||||
public FFmpegProcessBuilder WithErrorText(IDisplaySize desiredResolution, string text)
|
||||
{
|
||||
const string FONT_FILE = "fontfile=Resources/Roboto-Regular.ttf";
|
||||
string fontPath = Path.Combine(FileSystemLayout.ResourcesCacheFolder, "Roboto-Regular.ttf");
|
||||
var fontFile = $"fontfile={fontPath}";
|
||||
const string FONT_COLOR = "fontcolor=white";
|
||||
const string X = "x=(w-text_w)/2";
|
||||
const string Y = "y=(h-text_h)/3*2";
|
||||
@@ -233,7 +234,7 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
string fontSize = text.Length > 80 ? "fontsize=30" : text.Length > 60 ? "fontsize=40" : "fontsize=60";
|
||||
|
||||
return WithFilterComplex(
|
||||
$"[0:0]scale={desiredResolution.Width}:{desiredResolution.Height},drawtext={FONT_FILE}:{fontSize}:{FONT_COLOR}:{X}:{Y}:text='{text}'[v]",
|
||||
$"[0:0]scale={desiredResolution.Width}:{desiredResolution.Height},drawtext={fontFile}:{fontSize}:{FONT_COLOR}:{X}:{Y}:text='{text}'[v]",
|
||||
"[v]",
|
||||
"1:a");
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.FFmpeg;
|
||||
@@ -110,7 +111,7 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
.WithQuiet()
|
||||
.WithFormatFlags(playbackSettings.FormatFlags)
|
||||
.WithRealtimeOutput(playbackSettings.RealtimeOutput)
|
||||
.WithLoopedImage("Resources/background.png")
|
||||
.WithLoopedImage(Path.Combine(FileSystemLayout.ResourcesCacheFolder, "background.png"))
|
||||
.WithLibavfilter()
|
||||
.WithInput("anullsrc")
|
||||
.WithErrorText(desiredResolution, errorMessage)
|
||||
|
||||
@@ -16,6 +16,7 @@ namespace ErsatzTV.Core
|
||||
public static readonly string LogDatabasePath = Path.Combine(AppDataFolder, "logs.sqlite3");
|
||||
|
||||
public static readonly string LegacyImageCacheFolder = Path.Combine(AppDataFolder, "cache", "images");
|
||||
public static readonly string ResourcesCacheFolder = Path.Combine(AppDataFolder, "cache", "resources");
|
||||
|
||||
public static readonly string PlexSecretsPath = Path.Combine(AppDataFolder, "plex-secrets.json");
|
||||
public static readonly string JellyfinSecretsPath = Path.Combine(AppDataFolder, "jellyfin-secrets.json");
|
||||
|
||||
@@ -9,5 +9,6 @@ namespace ErsatzTV.Core.Interfaces.Images
|
||||
Task<Either<BaseError, byte[]>> ResizeImage(byte[] imageBuffer, int height);
|
||||
Task<Either<BaseError, string>> SaveArtworkToCache(byte[] imageBuffer, ArtworkKind artworkKind);
|
||||
Task<Either<BaseError, string>> CopyArtworkToCache(string path, ArtworkKind artworkKind);
|
||||
string GetPathForImage(string fileName, ArtworkKind artworkKind, Option<int> maybeMaxHeight);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using LanguageExt;
|
||||
|
||||
namespace ErsatzTV.Core.Interfaces.Repositories
|
||||
{
|
||||
public interface IArtworkRepository
|
||||
{
|
||||
Task<List<Artwork>> GetOrphanedArtwork();
|
||||
Task<Unit> Delete(List<Artwork> artwork);
|
||||
}
|
||||
}
|
||||
@@ -24,5 +24,9 @@ namespace ErsatzTV.Core.Interfaces.Repositories
|
||||
Task<Unit> MarkAsUpdated(SeasonMetadata metadata, DateTime dateUpdated);
|
||||
Task<Unit> MarkAsUpdated(MovieMetadata metadata, DateTime dateUpdated);
|
||||
Task<Unit> MarkAsUpdated(EpisodeMetadata metadata, DateTime dateUpdated);
|
||||
Task<Unit> MarkAsExternal(ShowMetadata metadata);
|
||||
Task<Unit> SetContentRating(ShowMetadata metadata, string contentRating);
|
||||
Task<Unit> MarkAsExternal(MovieMetadata metadata);
|
||||
Task<Unit> SetContentRating(MovieMetadata metadata, string contentRating);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,5 +33,9 @@ namespace ErsatzTV.Core.Interfaces.Repositories
|
||||
Task<List<int>> RemoveMissingEmbyMovies(EmbyLibrary library, List<string> movieIds);
|
||||
Task<bool> AddEmby(EmbyMovie movie);
|
||||
Task<Option<EmbyMovie>> UpdateEmby(EmbyMovie movie);
|
||||
Task<bool> RemoveDirector(Director director);
|
||||
Task<bool> AddDirector(MovieMetadata metadata, Director director);
|
||||
Task<bool> RemoveWriter(Writer writer);
|
||||
Task<bool> AddWriter(MovieMetadata metadata, Writer writer);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ using System.Linq;
|
||||
using System.Text;
|
||||
using System.Xml;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Emby;
|
||||
using ErsatzTV.Core.Jellyfin;
|
||||
using LanguageExt;
|
||||
using LanguageExt.UnsafeValueAccess;
|
||||
using static LanguageExt.Prelude;
|
||||
@@ -70,6 +72,19 @@ namespace ErsatzTV.Core.Iptv
|
||||
finishIndex++;
|
||||
}
|
||||
|
||||
int customShowId = -1;
|
||||
if (sorted[i].MediaItem is Episode ep)
|
||||
{
|
||||
customShowId = ep.Season.ShowId;
|
||||
}
|
||||
|
||||
bool isSameCustomShow = hasCustomTitle;
|
||||
for (int x = i; x <= finishIndex; x++)
|
||||
{
|
||||
isSameCustomShow = isSameCustomShow && sorted[x].MediaItem is Episode e &&
|
||||
customShowId == e.Season.ShowId;
|
||||
}
|
||||
|
||||
PlayoutItem finishItem = sorted[finishIndex];
|
||||
i = finishIndex;
|
||||
|
||||
@@ -108,9 +123,7 @@ namespace ErsatzTV.Core.Iptv
|
||||
string poster = Optional(metadata.Artwork).Flatten()
|
||||
.Filter(a => a.ArtworkKind == ArtworkKind.Poster)
|
||||
.HeadOrNone()
|
||||
.Match(
|
||||
artwork => $"{_scheme}://{_host}/iptv/artwork/posters/{artwork.Path}",
|
||||
() => string.Empty);
|
||||
.Match(a => GetArtworkUrl(a, ArtworkKind.Poster), () => string.Empty);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(poster))
|
||||
{
|
||||
@@ -137,52 +150,63 @@ namespace ErsatzTV.Core.Iptv
|
||||
xml.WriteStartElement("previously-shown");
|
||||
xml.WriteEndElement(); // previously-shown
|
||||
|
||||
if (!hasCustomTitle && startItem.MediaItem is Episode episode)
|
||||
if (startItem.MediaItem is Episode episode && (!hasCustomTitle || isSameCustomShow))
|
||||
{
|
||||
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)
|
||||
string artwork = Optional(metadata.Artwork).Flatten()
|
||||
.Filter(a => a.ArtworkKind == ArtworkKind.Thumbnail)
|
||||
.HeadOrNone()
|
||||
.Match(
|
||||
artwork => $"{_scheme}://{_host}/iptv/artwork/posters/{artwork.Path}",
|
||||
() => string.Empty);
|
||||
.Match(a => GetArtworkUrl(a, ArtworkKind.Thumbnail), () => string.Empty);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(poster))
|
||||
// fall back to poster
|
||||
if (string.IsNullOrWhiteSpace(artwork))
|
||||
{
|
||||
artwork = Optional(metadata.Artwork).Flatten()
|
||||
.Filter(a => a.ArtworkKind == ArtworkKind.Poster)
|
||||
.HeadOrNone()
|
||||
.Match(a => GetArtworkUrl(a, ArtworkKind.Poster), () => string.Empty);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(artwork))
|
||||
{
|
||||
xml.WriteStartElement("icon");
|
||||
xml.WriteAttributeString("src", poster);
|
||||
xml.WriteAttributeString("src", artwork);
|
||||
xml.WriteEndElement(); // icon
|
||||
}
|
||||
}
|
||||
|
||||
int s = Optional(episode.Season?.SeasonNumber).IfNone(0);
|
||||
int e = episode.EpisodeNumber;
|
||||
if (s > 0 && e > 0)
|
||||
if (!isSameCustomShow)
|
||||
{
|
||||
xml.WriteStartElement("episode-num");
|
||||
xml.WriteAttributeString("system", "onscreen");
|
||||
xml.WriteString($"S{s:00}E{e:00}");
|
||||
xml.WriteEndElement(); // episode-num
|
||||
int s = Optional(episode.Season?.SeasonNumber).IfNone(0);
|
||||
int e = episode.EpisodeNumber;
|
||||
if (s > 0 && e > 0)
|
||||
{
|
||||
xml.WriteStartElement("episode-num");
|
||||
xml.WriteAttributeString("system", "onscreen");
|
||||
xml.WriteString($"S{s:00}E{e:00}");
|
||||
xml.WriteEndElement(); // episode-num
|
||||
|
||||
xml.WriteStartElement("episode-num");
|
||||
xml.WriteAttributeString("system", "xmltv_ns");
|
||||
xml.WriteString($"{s - 1}.{e - 1}.0/1");
|
||||
xml.WriteEndElement(); // episode-num
|
||||
xml.WriteStartElement("episode-num");
|
||||
xml.WriteAttributeString("system", "xmltv_ns");
|
||||
xml.WriteString($"{s - 1}.{e - 1}.0/1");
|
||||
xml.WriteEndElement(); // episode-num
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// sb.AppendLine("<icon src=\"\"/>");
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(description))
|
||||
if (!isSameCustomShow)
|
||||
{
|
||||
xml.WriteStartElement("desc");
|
||||
xml.WriteAttributeString("lang", "en");
|
||||
xml.WriteString(description);
|
||||
xml.WriteEndElement(); // desc
|
||||
if (!string.IsNullOrWhiteSpace(description))
|
||||
{
|
||||
xml.WriteStartElement("desc");
|
||||
xml.WriteAttributeString("lang", "en");
|
||||
xml.WriteString(description);
|
||||
xml.WriteEndElement(); // desc
|
||||
}
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(contentRating))
|
||||
@@ -208,6 +232,40 @@ namespace ErsatzTV.Core.Iptv
|
||||
return Encoding.UTF8.GetString(ms.ToArray());
|
||||
}
|
||||
|
||||
private string GetArtworkUrl(Artwork artwork, ArtworkKind artworkKind)
|
||||
{
|
||||
string artworkPath = artwork.Path;
|
||||
|
||||
int height = artworkKind switch
|
||||
{
|
||||
ArtworkKind.Thumbnail => 220,
|
||||
_ => 440
|
||||
};
|
||||
|
||||
if (artworkPath.StartsWith("jellyfin://"))
|
||||
{
|
||||
artworkPath = JellyfinUrl.ProxyForArtwork(_scheme, _host, artworkPath, artworkKind)
|
||||
.SetQueryParam("fillHeight", height);
|
||||
}
|
||||
else if (artworkPath.StartsWith("emby://"))
|
||||
{
|
||||
artworkPath = EmbyUrl.ProxyForArtwork(_scheme, _host, artworkPath, artworkKind)
|
||||
.SetQueryParam("maxHeight", height);
|
||||
}
|
||||
else
|
||||
{
|
||||
string artworkFolder = artworkKind switch
|
||||
{
|
||||
ArtworkKind.Thumbnail => "thumbnails",
|
||||
_ => "posters"
|
||||
};
|
||||
|
||||
artworkPath = $"{_scheme}://{_host}/iptv/artwork/{artworkFolder}/{artwork.Path}";
|
||||
}
|
||||
|
||||
return artworkPath;
|
||||
}
|
||||
|
||||
private static string GetTitle(PlayoutItem playoutItem)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(playoutItem.CustomTitle))
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Collections.Generic;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Threading.Tasks;
|
||||
@@ -44,7 +45,7 @@ namespace ErsatzTV.Core.Jellyfin
|
||||
.SingleOrDefault(
|
||||
r =>
|
||||
{
|
||||
string separatorChar = IsWindows(r.JellyfinMediaSource) ? @"\" : @"/";
|
||||
string separatorChar = IsWindows(r.JellyfinMediaSource, path) ? @"\" : @"/";
|
||||
string prefix = r.JellyfinPath.EndsWith(separatorChar)
|
||||
? r.JellyfinPath
|
||||
: r.JellyfinPath + separatorChar;
|
||||
@@ -55,11 +56,12 @@ namespace ErsatzTV.Core.Jellyfin
|
||||
replacement =>
|
||||
{
|
||||
string finalPath = path.Replace(replacement.JellyfinPath, replacement.LocalPath);
|
||||
if (IsWindows(replacement.JellyfinMediaSource) && !_runtimeInfo.IsOSPlatform(OSPlatform.Windows))
|
||||
if (IsWindows(replacement.JellyfinMediaSource, path) &&
|
||||
!_runtimeInfo.IsOSPlatform(OSPlatform.Windows))
|
||||
{
|
||||
finalPath = finalPath.Replace(@"\", @"/");
|
||||
}
|
||||
else if (!IsWindows(replacement.JellyfinMediaSource) &&
|
||||
else if (!IsWindows(replacement.JellyfinMediaSource, path) &&
|
||||
_runtimeInfo.IsOSPlatform(OSPlatform.Windows))
|
||||
{
|
||||
finalPath = finalPath.Replace(@"/", @"\");
|
||||
@@ -79,7 +81,10 @@ namespace ErsatzTV.Core.Jellyfin
|
||||
() => path);
|
||||
}
|
||||
|
||||
private static bool IsWindows(JellyfinMediaSource jellyfinMediaSource) =>
|
||||
jellyfinMediaSource.OperatingSystem.ToLowerInvariant().StartsWith("windows");
|
||||
private static bool IsWindows(JellyfinMediaSource jellyfinMediaSource, string path)
|
||||
{
|
||||
bool isUnc = Uri.TryCreate(path, UriKind.Absolute, out Uri uri) && uri.IsUnc;
|
||||
return isUnc || jellyfinMediaSource.OperatingSystem.ToLowerInvariant().StartsWith("windows");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,11 +21,47 @@ namespace ErsatzTV.Core.Jellyfin
|
||||
string pathSegment = split[0];
|
||||
QueryParamCollection query = Url.ParseQueryParams(split[1]);
|
||||
|
||||
Url x = Url.Parse(address)
|
||||
return Url.Parse(address)
|
||||
.AppendPathSegment(pathSegment)
|
||||
.SetQueryParams(query);
|
||||
}
|
||||
|
||||
return x;
|
||||
public static Url ForArtwork(string address, string artwork)
|
||||
{
|
||||
string[] split = artwork.Replace("jellyfin://", string.Empty).Split('?');
|
||||
if (split.Length != 2)
|
||||
{
|
||||
return artwork;
|
||||
}
|
||||
|
||||
string pathSegment = split[0];
|
||||
QueryParamCollection query = Url.ParseQueryParams(split[1]);
|
||||
|
||||
return Url.Parse(address)
|
||||
.AppendPathSegment(pathSegment)
|
||||
.SetQueryParams(query);
|
||||
}
|
||||
|
||||
public static Url ProxyForArtwork(string scheme, string host, string artwork, ArtworkKind artworkKind)
|
||||
{
|
||||
string[] split = artwork.Replace("jellyfin://", string.Empty).Split('?');
|
||||
if (split.Length != 2)
|
||||
{
|
||||
return artwork;
|
||||
}
|
||||
|
||||
string pathSegment = split[0];
|
||||
QueryParamCollection query = Url.ParseQueryParams(split[1]);
|
||||
|
||||
string artworkFolder = artworkKind switch
|
||||
{
|
||||
ArtworkKind.Thumbnail => "thumbnails",
|
||||
_ => "posters"
|
||||
};
|
||||
|
||||
return Url.Parse($"{scheme}://{host}/iptv/artwork/{artworkFolder}/jellyfin")
|
||||
.AppendPathSegment(pathSegment)
|
||||
.SetQueryParams(query);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -232,6 +232,7 @@ namespace ErsatzTV.Core.Metadata
|
||||
Optional(movie.MovieMetadata).Flatten().HeadOrNone().Match(
|
||||
async existing =>
|
||||
{
|
||||
existing.ContentRating = metadata.ContentRating;
|
||||
existing.Outline = metadata.Outline;
|
||||
existing.Plot = metadata.Plot;
|
||||
existing.Tagline = metadata.Tagline;
|
||||
@@ -259,6 +260,46 @@ namespace ErsatzTV.Core.Metadata
|
||||
_movieRepository.AddStudio,
|
||||
_movieRepository.AddActor);
|
||||
|
||||
foreach (Director director in existing.Directors
|
||||
.Filter(g => metadata.Directors.All(g2 => g2.Name != g.Name)).ToList())
|
||||
{
|
||||
existing.Directors.Remove(director);
|
||||
if (await _movieRepository.RemoveDirector(director))
|
||||
{
|
||||
updated = true;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (Director director in metadata.Directors
|
||||
.Filter(g => existing.Directors.All(g2 => g2.Name != g.Name)).ToList())
|
||||
{
|
||||
existing.Directors.Add(director);
|
||||
if (await _movieRepository.AddDirector(existing, director))
|
||||
{
|
||||
updated = true;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (Writer writer in existing.Writers
|
||||
.Filter(g => metadata.Writers.All(g2 => g2.Name != g.Name)).ToList())
|
||||
{
|
||||
existing.Writers.Remove(writer);
|
||||
if (await _movieRepository.RemoveWriter(writer))
|
||||
{
|
||||
updated = true;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (Writer writer in metadata.Writers
|
||||
.Filter(g => existing.Writers.All(g2 => g2.Name != g.Name)).ToList())
|
||||
{
|
||||
existing.Writers.Add(writer);
|
||||
if (await _movieRepository.AddWriter(existing, writer))
|
||||
{
|
||||
updated = true;
|
||||
}
|
||||
}
|
||||
|
||||
return await _metadataRepository.Update(existing) || updated;
|
||||
},
|
||||
async () =>
|
||||
@@ -276,6 +317,7 @@ namespace ErsatzTV.Core.Metadata
|
||||
Optional(show.ShowMetadata).Flatten().HeadOrNone().Match(
|
||||
async existing =>
|
||||
{
|
||||
existing.ContentRating = metadata.ContentRating;
|
||||
existing.Outline = metadata.Outline;
|
||||
existing.Plot = metadata.Plot;
|
||||
existing.Tagline = metadata.Tagline;
|
||||
@@ -474,6 +516,7 @@ namespace ErsatzTV.Core.Metadata
|
||||
Plot = nfo.Plot,
|
||||
Outline = nfo.Outline,
|
||||
Tagline = nfo.Tagline,
|
||||
ContentRating = nfo.ContentRating,
|
||||
Year = GetYear(nfo.Year, nfo.Premiered),
|
||||
ReleaseDate = GetAired(nfo.Year, nfo.Premiered),
|
||||
Genres = nfo.Genres.Map(g => new Genre { Name = g }).ToList(),
|
||||
@@ -571,6 +614,7 @@ namespace ErsatzTV.Core.Metadata
|
||||
DateUpdated = dateUpdated,
|
||||
Title = nfo.Title,
|
||||
Year = nfo.Year,
|
||||
ContentRating = nfo.ContentRating,
|
||||
ReleaseDate = nfo.Premiered,
|
||||
Plot = nfo.Plot,
|
||||
Outline = nfo.Outline,
|
||||
@@ -578,7 +622,9 @@ namespace ErsatzTV.Core.Metadata
|
||||
Genres = nfo.Genres.Map(g => new Genre { Name = g }).ToList(),
|
||||
Tags = nfo.Tags.Map(t => new Tag { Name = t }).ToList(),
|
||||
Studios = nfo.Studios.Map(s => new Studio { Name = s }).ToList(),
|
||||
Actors = Actors(nfo.Actors, dateAdded, dateUpdated)
|
||||
Actors = Actors(nfo.Actors, dateAdded, dateUpdated),
|
||||
Directors = nfo.Directors.Map(d => new Director { Name = d }).ToList(),
|
||||
Writers = nfo.Writers.Map(w => new Writer { Name = w }).ToList()
|
||||
};
|
||||
},
|
||||
None);
|
||||
|
||||
@@ -108,7 +108,7 @@ namespace ErsatzTV.Core.Metadata
|
||||
.HeadOrNone();
|
||||
|
||||
// skip folder if etag matches
|
||||
if (await knownFolder.Map(f => f.Etag).IfNoneAsync(string.Empty) == etag)
|
||||
if (await knownFolder.Map(f => f.Etag ?? string.Empty).IfNoneAsync(string.Empty) == etag)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -252,7 +252,7 @@ namespace ErsatzTV.Core.Metadata
|
||||
.HeadOrNone();
|
||||
|
||||
// skip folder if etag matches
|
||||
if (await knownFolder.Map(f => f.Etag).IfNoneAsync(string.Empty) == etag)
|
||||
if (await knownFolder.Map(f => f.Etag ?? string.Empty).IfNoneAsync(string.Empty) == etag)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -39,5 +39,11 @@ namespace ErsatzTV.Core.Metadata.Nfo
|
||||
|
||||
[XmlElement("actor")]
|
||||
public List<ActorNfo> Actors { get; set; }
|
||||
|
||||
[XmlElement("credits")]
|
||||
public List<string> Writers { get; set; }
|
||||
|
||||
[XmlElement("director")]
|
||||
public List<string> Directors { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,9 @@ namespace ErsatzTV.Core.Metadata.Nfo
|
||||
[XmlElement("tagline")]
|
||||
public string Tagline { get; set; }
|
||||
|
||||
[XmlElement("mpaa")]
|
||||
public string ContentRating { get; set; }
|
||||
|
||||
[XmlElement("premiered")]
|
||||
public string Premiered { get; set; }
|
||||
|
||||
|
||||
@@ -84,7 +84,8 @@ namespace ErsatzTV.Core.Metadata
|
||||
await FindOrCreateShow(libraryPath.Id, showFolder)
|
||||
.BindT(show => UpdateMetadataForShow(show, showFolder))
|
||||
.BindT(show => UpdateArtworkForShow(show, showFolder, ArtworkKind.Poster))
|
||||
.BindT(show => UpdateArtworkForShow(show, showFolder, ArtworkKind.FanArt));
|
||||
.BindT(show => UpdateArtworkForShow(show, showFolder, ArtworkKind.FanArt))
|
||||
.BindT(show => UpdateArtworkForShow(show, showFolder, ArtworkKind.Thumbnail));
|
||||
|
||||
await maybeShow.Match(
|
||||
async result =>
|
||||
@@ -157,7 +158,7 @@ namespace ErsatzTV.Core.Metadata
|
||||
.HeadOrNone();
|
||||
|
||||
// skip folder if etag matches
|
||||
if (await knownFolder.Map(f => f.Etag).IfNoneAsync(string.Empty) == etag)
|
||||
if (await knownFolder.Map(f => f.Etag ?? string.Empty).IfNoneAsync(string.Empty) == etag)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
@@ -309,10 +310,10 @@ namespace ErsatzTV.Core.Metadata
|
||||
{
|
||||
Show show = result.Item;
|
||||
await LocateArtworkForShow(showFolder, artworkKind).IfSomeAsync(
|
||||
async posterFile =>
|
||||
async artworkFile =>
|
||||
{
|
||||
ShowMetadata metadata = show.ShowMetadata.Head();
|
||||
await RefreshArtwork(posterFile, metadata, artworkKind);
|
||||
await RefreshArtwork(artworkFile, metadata, artworkKind);
|
||||
});
|
||||
|
||||
return result;
|
||||
@@ -378,6 +379,7 @@ namespace ErsatzTV.Core.Metadata
|
||||
{
|
||||
ArtworkKind.Poster => new[] { "poster", "folder" },
|
||||
ArtworkKind.FanArt => new[] { "fanart" },
|
||||
ArtworkKind.Thumbnail => new[] { "thumb" },
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(artworkKind))
|
||||
};
|
||||
|
||||
|
||||
@@ -173,6 +173,19 @@ namespace ErsatzTV.Core.Plex
|
||||
await maybeMetadata.Match(
|
||||
async fullMetadata =>
|
||||
{
|
||||
if (existingMetadata.MetadataKind != MetadataKind.External)
|
||||
{
|
||||
existingMetadata.MetadataKind = MetadataKind.External;
|
||||
await _metadataRepository.MarkAsExternal(existingMetadata);
|
||||
}
|
||||
|
||||
if (existingMetadata.ContentRating != fullMetadata.ContentRating)
|
||||
{
|
||||
existingMetadata.ContentRating = fullMetadata.ContentRating;
|
||||
await _metadataRepository.SetContentRating(existingMetadata, fullMetadata.ContentRating);
|
||||
result.IsUpdated = true;
|
||||
}
|
||||
|
||||
foreach (Genre genre in existingMetadata.Genres
|
||||
.Filter(g => fullMetadata.Genres.All(g2 => g2.Name != g.Name))
|
||||
.ToList())
|
||||
@@ -241,6 +254,50 @@ namespace ErsatzTV.Core.Plex
|
||||
}
|
||||
}
|
||||
|
||||
foreach (Director director in existingMetadata.Directors
|
||||
.Filter(g => fullMetadata.Directors.All(g2 => g2.Name != g.Name))
|
||||
.ToList())
|
||||
{
|
||||
existingMetadata.Directors.Remove(director);
|
||||
if (await _movieRepository.RemoveDirector(director))
|
||||
{
|
||||
result.IsUpdated = true;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (Director director in fullMetadata.Directors
|
||||
.Filter(g => existingMetadata.Directors.All(g2 => g2.Name != g.Name))
|
||||
.ToList())
|
||||
{
|
||||
existingMetadata.Directors.Add(director);
|
||||
if (await _movieRepository.AddDirector(existingMetadata, director))
|
||||
{
|
||||
result.IsUpdated = true;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (Writer writer in existingMetadata.Writers
|
||||
.Filter(g => fullMetadata.Writers.All(g2 => g2.Name != g.Name))
|
||||
.ToList())
|
||||
{
|
||||
existingMetadata.Writers.Remove(writer);
|
||||
if (await _movieRepository.RemoveWriter(writer))
|
||||
{
|
||||
result.IsUpdated = true;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (Writer writer in fullMetadata.Writers
|
||||
.Filter(g => existingMetadata.Writers.All(g2 => g2.Name != g.Name))
|
||||
.ToList())
|
||||
{
|
||||
existingMetadata.Writers.Add(writer);
|
||||
if (await _movieRepository.AddWriter(existingMetadata, writer))
|
||||
{
|
||||
result.IsUpdated = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (fullMetadata.SortTitle != existingMetadata.SortTitle)
|
||||
{
|
||||
existingMetadata.SortTitle = fullMetadata.SortTitle;
|
||||
|
||||
@@ -146,6 +146,19 @@ namespace ErsatzTV.Core.Plex
|
||||
await maybeMetadata.Match(
|
||||
async fullMetadata =>
|
||||
{
|
||||
if (existingMetadata.MetadataKind != MetadataKind.External)
|
||||
{
|
||||
existingMetadata.MetadataKind = MetadataKind.External;
|
||||
await _metadataRepository.MarkAsExternal(existingMetadata);
|
||||
}
|
||||
|
||||
if (existingMetadata.ContentRating != fullMetadata.ContentRating)
|
||||
{
|
||||
existingMetadata.ContentRating = fullMetadata.ContentRating;
|
||||
await _metadataRepository.SetContentRating(existingMetadata, fullMetadata.ContentRating);
|
||||
result.IsUpdated = true;
|
||||
}
|
||||
|
||||
foreach (Genre genre in existingMetadata.Genres
|
||||
.Filter(g => fullMetadata.Genres.All(g2 => g2.Name != g.Name))
|
||||
.ToList())
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace ErsatzTV.Infrastructure.Data.Configurations
|
||||
{
|
||||
public class DirectorConfiguration : IEntityTypeConfiguration<Director>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<Director> builder) => builder.ToTable("Director");
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,14 @@ namespace ErsatzTV.Infrastructure.Data.Configurations
|
||||
builder.HasMany(mm => mm.Actors)
|
||||
.WithOne()
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
builder.HasMany(mm => mm.Directors)
|
||||
.WithOne()
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
builder.HasMany(mm => mm.Writers)
|
||||
.WithOne()
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace ErsatzTV.Infrastructure.Data.Configurations
|
||||
{
|
||||
public class WriterConfiguration : IEntityTypeConfiguration<Writer>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<Writer> builder) => builder.ToTable("Writer");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Data;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Dapper;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using LanguageExt;
|
||||
|
||||
namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
{
|
||||
public class ArtworkRepository : IArtworkRepository
|
||||
{
|
||||
private readonly IDbConnection _dbConnection;
|
||||
|
||||
public ArtworkRepository(IDbConnection dbConnection) => _dbConnection = dbConnection;
|
||||
|
||||
public Task<List<Artwork>> GetOrphanedArtwork() =>
|
||||
_dbConnection.QueryAsync<Artwork>(
|
||||
@"SELECT A.Id, A.Path FROM Artwork A
|
||||
WHERE A.ArtistMetadataId IS NULL AND A.EpisodeMetadataId IS NULL
|
||||
AND A.SeasonMetadataId IS NULL AND A.ShowMetadataId IS NULL
|
||||
AND A.MovieMetadataId IS NULL AND A.MusicVideoMetadataId IS NULL
|
||||
AND NOT EXISTS (SELECT * FROM Actor WHERE Actor.ArtworkId = A.Id)")
|
||||
.Map(result => result.ToList());
|
||||
|
||||
public async Task<Unit> Delete(List<Artwork> artwork)
|
||||
{
|
||||
IEnumerable<List<int>> chunks = Chunk(artwork.Map(a => a.Id), 100);
|
||||
foreach (List<int> chunk in chunks)
|
||||
{
|
||||
await _dbConnection.ExecuteAsync(
|
||||
"DELETE FROM Artwork WHERE Id IN @Ids",
|
||||
new { Ids = chunk });
|
||||
}
|
||||
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
private static IEnumerable<List<T>> Chunk<T>(IEnumerable<T> collection, int size)
|
||||
{
|
||||
var count = 0;
|
||||
var chunk = new List<T>(size);
|
||||
|
||||
foreach (T element in collection)
|
||||
{
|
||||
if (count++ == size)
|
||||
{
|
||||
yield return chunk;
|
||||
chunk = new List<T>(size);
|
||||
count = 1;
|
||||
}
|
||||
|
||||
chunk.Add(element);
|
||||
}
|
||||
|
||||
yield return chunk;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -105,6 +105,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
// metadata
|
||||
ShowMetadata metadata = existing.ShowMetadata.Head();
|
||||
ShowMetadata incomingMetadata = show.ShowMetadata.Head();
|
||||
metadata.MetadataKind = incomingMetadata.MetadataKind;
|
||||
metadata.ContentRating = incomingMetadata.ContentRating;
|
||||
metadata.Title = incomingMetadata.Title;
|
||||
metadata.SortTitle = incomingMetadata.SortTitle;
|
||||
metadata.Plot = incomingMetadata.Plot;
|
||||
@@ -210,6 +212,12 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
fanArt.DateAdded = incomingFanArt.DateAdded;
|
||||
fanArt.DateUpdated = incomingFanArt.DateUpdated;
|
||||
}
|
||||
|
||||
var paths = incomingMetadata.Artwork.Map(a => a.Path).ToList();
|
||||
foreach (Artwork artworkToRemove in metadata.Artwork.Filter(a => !paths.Contains(a.Path)))
|
||||
{
|
||||
metadata.Artwork.Remove(artworkToRemove);
|
||||
}
|
||||
}
|
||||
|
||||
await dbContext.SaveChangesAsync();
|
||||
@@ -280,6 +288,23 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
poster.DateUpdated = incomingPoster.DateUpdated;
|
||||
}
|
||||
|
||||
// thumbnail
|
||||
Artwork incomingThumbnail =
|
||||
incomingMetadata.Artwork.FirstOrDefault(a => a.ArtworkKind == ArtworkKind.Thumbnail);
|
||||
if (incomingThumbnail != null)
|
||||
{
|
||||
Artwork thumb = metadata.Artwork.FirstOrDefault(a => a.ArtworkKind == ArtworkKind.Thumbnail);
|
||||
if (thumb == null)
|
||||
{
|
||||
thumb = new Artwork { ArtworkKind = ArtworkKind.Thumbnail };
|
||||
metadata.Artwork.Add(thumb);
|
||||
}
|
||||
|
||||
thumb.Path = incomingThumbnail.Path;
|
||||
thumb.DateAdded = incomingThumbnail.DateAdded;
|
||||
thumb.DateUpdated = incomingThumbnail.DateUpdated;
|
||||
}
|
||||
|
||||
// fan art
|
||||
Artwork incomingFanArt =
|
||||
incomingMetadata.Artwork.FirstOrDefault(a => a.ArtworkKind == ArtworkKind.FanArt);
|
||||
@@ -296,6 +321,12 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
fanArt.DateAdded = incomingFanArt.DateAdded;
|
||||
fanArt.DateUpdated = incomingFanArt.DateUpdated;
|
||||
}
|
||||
|
||||
var paths = incomingMetadata.Artwork.Map(a => a.Path).ToList();
|
||||
foreach (Artwork artworkToRemove in metadata.Artwork.Filter(a => !paths.Contains(a.Path)))
|
||||
{
|
||||
metadata.Artwork.Remove(artworkToRemove);
|
||||
}
|
||||
}
|
||||
|
||||
await dbContext.SaveChangesAsync();
|
||||
@@ -371,6 +402,12 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
thumbnail.DateUpdated = incomingThumbnail.DateUpdated;
|
||||
}
|
||||
|
||||
var paths = incomingMetadata.Artwork.Map(a => a.Path).ToList();
|
||||
foreach (Artwork artworkToRemove in metadata.Artwork.Filter(a => !paths.Contains(a.Path)))
|
||||
{
|
||||
metadata.Artwork.Remove(artworkToRemove);
|
||||
}
|
||||
|
||||
// version
|
||||
MediaVersion version = existing.MediaVersions.Head();
|
||||
MediaVersion incomingVersion = episode.MediaVersions.Head();
|
||||
|
||||
@@ -105,6 +105,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
// metadata
|
||||
ShowMetadata metadata = existing.ShowMetadata.Head();
|
||||
ShowMetadata incomingMetadata = show.ShowMetadata.Head();
|
||||
metadata.MetadataKind = incomingMetadata.MetadataKind;
|
||||
metadata.ContentRating = incomingMetadata.ContentRating;
|
||||
metadata.Title = incomingMetadata.Title;
|
||||
metadata.SortTitle = incomingMetadata.SortTitle;
|
||||
metadata.Plot = incomingMetadata.Plot;
|
||||
@@ -194,6 +196,23 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
poster.DateUpdated = incomingPoster.DateUpdated;
|
||||
}
|
||||
|
||||
// thumbnail
|
||||
Artwork incomingThumbnail =
|
||||
incomingMetadata.Artwork.FirstOrDefault(a => a.ArtworkKind == ArtworkKind.Thumbnail);
|
||||
if (incomingThumbnail != null)
|
||||
{
|
||||
Artwork thumb = metadata.Artwork.FirstOrDefault(a => a.ArtworkKind == ArtworkKind.Thumbnail);
|
||||
if (thumb == null)
|
||||
{
|
||||
thumb = new Artwork { ArtworkKind = ArtworkKind.Thumbnail };
|
||||
metadata.Artwork.Add(thumb);
|
||||
}
|
||||
|
||||
thumb.Path = incomingThumbnail.Path;
|
||||
thumb.DateAdded = incomingThumbnail.DateAdded;
|
||||
thumb.DateUpdated = incomingThumbnail.DateUpdated;
|
||||
}
|
||||
|
||||
// fan art
|
||||
Artwork incomingFanArt =
|
||||
incomingMetadata.Artwork.FirstOrDefault(a => a.ArtworkKind == ArtworkKind.FanArt);
|
||||
@@ -210,6 +229,12 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
fanArt.DateAdded = incomingFanArt.DateAdded;
|
||||
fanArt.DateUpdated = incomingFanArt.DateUpdated;
|
||||
}
|
||||
|
||||
var paths = incomingMetadata.Artwork.Map(a => a.Path).ToList();
|
||||
foreach (Artwork artworkToRemove in metadata.Artwork.Filter(a => !paths.Contains(a.Path)))
|
||||
{
|
||||
metadata.Artwork.Remove(artworkToRemove);
|
||||
}
|
||||
}
|
||||
|
||||
await dbContext.SaveChangesAsync();
|
||||
@@ -296,6 +321,12 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
fanArt.DateAdded = incomingFanArt.DateAdded;
|
||||
fanArt.DateUpdated = incomingFanArt.DateUpdated;
|
||||
}
|
||||
|
||||
var paths = incomingMetadata.Artwork.Map(a => a.Path).ToList();
|
||||
foreach (Artwork artworkToRemove in metadata.Artwork.Filter(a => !paths.Contains(a.Path)))
|
||||
{
|
||||
metadata.Artwork.Remove(artworkToRemove);
|
||||
}
|
||||
}
|
||||
|
||||
await dbContext.SaveChangesAsync();
|
||||
@@ -371,6 +402,12 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
thumbnail.DateUpdated = incomingThumbnail.DateUpdated;
|
||||
}
|
||||
|
||||
var paths = incomingMetadata.Artwork.Map(a => a.Path).ToList();
|
||||
foreach (Artwork artworkToRemove in metadata.Artwork.Filter(a => !paths.Contains(a.Path)))
|
||||
{
|
||||
metadata.Artwork.Remove(artworkToRemove);
|
||||
}
|
||||
|
||||
// version
|
||||
MediaVersion version = existing.MediaVersions.Head();
|
||||
MediaVersion incomingVersion = episode.MediaVersions.Head();
|
||||
|
||||
@@ -75,6 +75,19 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
}
|
||||
}
|
||||
|
||||
if (metadata is MovieMetadata movieMetadata)
|
||||
{
|
||||
foreach (Director director in Optional(movieMetadata.Directors).Flatten())
|
||||
{
|
||||
dbContext.Entry(director).State = EntityState.Added;
|
||||
}
|
||||
|
||||
foreach (Writer writer in Optional(movieMetadata.Writers).Flatten())
|
||||
{
|
||||
dbContext.Entry(writer).State = EntityState.Added;
|
||||
}
|
||||
}
|
||||
|
||||
return await dbContext.SaveChangesAsync() > 0;
|
||||
}
|
||||
|
||||
@@ -228,6 +241,26 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
@"UPDATE EpisodeMetadata SET DateUpdated = @DateUpdated WHERE Id = @Id",
|
||||
new { DateUpdated = dateUpdated, metadata.Id }).ToUnit();
|
||||
|
||||
public Task<Unit> MarkAsExternal(ShowMetadata metadata) =>
|
||||
_dbConnection.ExecuteAsync(
|
||||
@"UPDATE ShowMetadata SET MetadataKind = @Kind WHERE Id = @Id",
|
||||
new { metadata.Id, Kind = (int) MetadataKind.External }).ToUnit();
|
||||
|
||||
public Task<Unit> SetContentRating(ShowMetadata metadata, string contentRating) =>
|
||||
_dbConnection.ExecuteAsync(
|
||||
@"UPDATE ShowMetadata SET ContentRating = @ContentRating WHERE Id = @Id",
|
||||
new { metadata.Id, ContentRating = contentRating }).ToUnit();
|
||||
|
||||
public Task<Unit> MarkAsExternal(MovieMetadata metadata) =>
|
||||
_dbConnection.ExecuteAsync(
|
||||
@"UPDATE MovieMetadata SET MetadataKind = @Kind WHERE Id = @Id",
|
||||
new { metadata.Id, Kind = (int) MetadataKind.External }).ToUnit();
|
||||
|
||||
public Task<Unit> SetContentRating(MovieMetadata metadata, string contentRating) =>
|
||||
_dbConnection.ExecuteAsync(
|
||||
@"UPDATE MovieMetadata SET ContentRating = @ContentRating WHERE Id = @Id",
|
||||
new { metadata.Id, ContentRating = contentRating }).ToUnit();
|
||||
|
||||
public Task<bool> RemoveGenre(Genre genre) =>
|
||||
_dbConnection.ExecuteAsync("DELETE FROM Genre WHERE Id = @GenreId", new { GenreId = genre.Id })
|
||||
.Map(result => result > 0);
|
||||
|
||||
@@ -49,6 +49,10 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
.Include(m => m.MovieMetadata)
|
||||
.ThenInclude(m => m.Actors)
|
||||
.ThenInclude(a => a.Artwork)
|
||||
.Include(i => i.MovieMetadata)
|
||||
.ThenInclude(mm => mm.Directors)
|
||||
.Include(i => i.MovieMetadata)
|
||||
.ThenInclude(mm => mm.Writers)
|
||||
.Include(m => m.MediaVersions)
|
||||
.ThenInclude(mv => mv.Streams)
|
||||
.OrderBy(m => m.Id)
|
||||
@@ -70,7 +74,13 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
.ThenInclude(mm => mm.Studios)
|
||||
.Include(i => i.MovieMetadata)
|
||||
.ThenInclude(mm => mm.Actors)
|
||||
.Include(i => i.MovieMetadata)
|
||||
.ThenInclude(mm => mm.Actors)
|
||||
.ThenInclude(a => a.Artwork)
|
||||
.Include(i => i.MovieMetadata)
|
||||
.ThenInclude(mm => mm.Directors)
|
||||
.Include(i => i.MovieMetadata)
|
||||
.ThenInclude(mm => mm.Writers)
|
||||
.Include(i => i.LibraryPath)
|
||||
.ThenInclude(lp => lp.Library)
|
||||
.Include(i => i.MediaVersions)
|
||||
@@ -105,6 +115,10 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
.ThenInclude(a => a.Artwork)
|
||||
.Include(i => i.MovieMetadata)
|
||||
.ThenInclude(mm => mm.Artwork)
|
||||
.Include(i => i.MovieMetadata)
|
||||
.ThenInclude(mm => mm.Directors)
|
||||
.Include(i => i.MovieMetadata)
|
||||
.ThenInclude(mm => mm.Writers)
|
||||
.Include(i => i.MediaVersions)
|
||||
.ThenInclude(mv => mv.MediaFiles)
|
||||
.Include(i => i.MediaVersions)
|
||||
@@ -310,6 +324,10 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
.ThenInclude(mm => mm.Actors)
|
||||
.Include(m => m.MovieMetadata)
|
||||
.ThenInclude(mm => mm.Artwork)
|
||||
.Include(m => m.MovieMetadata)
|
||||
.ThenInclude(mm => mm.Directors)
|
||||
.Include(m => m.MovieMetadata)
|
||||
.ThenInclude(mm => mm.Writers)
|
||||
.Filter(m => m.ItemId == movie.ItemId)
|
||||
.OrderBy(m => m.ItemId)
|
||||
.SingleOrDefaultAsync();
|
||||
@@ -327,6 +345,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
// metadata
|
||||
MovieMetadata metadata = existing.MovieMetadata.Head();
|
||||
MovieMetadata incomingMetadata = movie.MovieMetadata.Head();
|
||||
metadata.MetadataKind = incomingMetadata.MetadataKind;
|
||||
metadata.ContentRating = incomingMetadata.ContentRating;
|
||||
metadata.Title = incomingMetadata.Title;
|
||||
metadata.SortTitle = incomingMetadata.SortTitle;
|
||||
metadata.Plot = incomingMetadata.Plot;
|
||||
@@ -397,6 +417,36 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
metadata.Actors.Add(actor);
|
||||
}
|
||||
|
||||
// directors
|
||||
foreach (Director director in metadata.Directors
|
||||
.Filter(d => incomingMetadata.Directors.All(d2 => d2.Name != d.Name))
|
||||
.ToList())
|
||||
{
|
||||
metadata.Directors.Remove(director);
|
||||
}
|
||||
|
||||
foreach (Director director in incomingMetadata.Directors
|
||||
.Filter(d => metadata.Directors.All(d2 => d2.Name != d.Name))
|
||||
.ToList())
|
||||
{
|
||||
metadata.Directors.Add(director);
|
||||
}
|
||||
|
||||
// writers
|
||||
foreach (Writer writer in metadata.Writers
|
||||
.Filter(w => incomingMetadata.Writers.All(w2 => w2.Name != w.Name))
|
||||
.ToList())
|
||||
{
|
||||
metadata.Writers.Remove(writer);
|
||||
}
|
||||
|
||||
foreach (Writer writer in incomingMetadata.Writers
|
||||
.Filter(w => metadata.Writers.All(w2 => w2.Name != w.Name))
|
||||
.ToList())
|
||||
{
|
||||
metadata.Writers.Add(writer);
|
||||
}
|
||||
|
||||
metadata.ReleaseDate = incomingMetadata.ReleaseDate;
|
||||
|
||||
// poster
|
||||
@@ -510,6 +560,10 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
.Include(m => m.MovieMetadata)
|
||||
.ThenInclude(mm => mm.Actors)
|
||||
.Include(m => m.MovieMetadata)
|
||||
.ThenInclude(mm => mm.Directors)
|
||||
.Include(m => m.MovieMetadata)
|
||||
.ThenInclude(mm => mm.Writers)
|
||||
.Include(m => m.MovieMetadata)
|
||||
.ThenInclude(mm => mm.Artwork)
|
||||
.Filter(m => m.ItemId == movie.ItemId)
|
||||
.OrderBy(m => m.ItemId)
|
||||
@@ -528,6 +582,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
// metadata
|
||||
MovieMetadata metadata = existing.MovieMetadata.Head();
|
||||
MovieMetadata incomingMetadata = movie.MovieMetadata.Head();
|
||||
metadata.MetadataKind = incomingMetadata.MetadataKind;
|
||||
metadata.ContentRating = incomingMetadata.ContentRating;
|
||||
metadata.Title = incomingMetadata.Title;
|
||||
metadata.SortTitle = incomingMetadata.SortTitle;
|
||||
metadata.Plot = incomingMetadata.Plot;
|
||||
@@ -598,6 +654,36 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
metadata.Actors.Add(actor);
|
||||
}
|
||||
|
||||
// directors
|
||||
foreach (Director director in metadata.Directors
|
||||
.Filter(d => incomingMetadata.Directors.All(d2 => d2.Name != d.Name))
|
||||
.ToList())
|
||||
{
|
||||
metadata.Directors.Remove(director);
|
||||
}
|
||||
|
||||
foreach (Director director in incomingMetadata.Directors
|
||||
.Filter(d => metadata.Directors.All(d2 => d2.Name != d.Name))
|
||||
.ToList())
|
||||
{
|
||||
metadata.Directors.Add(director);
|
||||
}
|
||||
|
||||
// writers
|
||||
foreach (Writer writer in metadata.Writers
|
||||
.Filter(w => incomingMetadata.Writers.All(w2 => w2.Name != w.Name))
|
||||
.ToList())
|
||||
{
|
||||
metadata.Writers.Remove(writer);
|
||||
}
|
||||
|
||||
foreach (Writer writer in incomingMetadata.Writers
|
||||
.Filter(w => metadata.Writers.All(w2 => w2.Name != w.Name))
|
||||
.ToList())
|
||||
{
|
||||
metadata.Writers.Add(writer);
|
||||
}
|
||||
|
||||
metadata.ReleaseDate = incomingMetadata.ReleaseDate;
|
||||
|
||||
// poster
|
||||
@@ -651,6 +737,24 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
return maybeExisting;
|
||||
}
|
||||
|
||||
public Task<bool> RemoveDirector(Director director) =>
|
||||
_dbConnection.ExecuteAsync("DELETE FROM Director WHERE Id = @DirectorId", new { DirectorId = director.Id })
|
||||
.Map(result => result > 0);
|
||||
|
||||
public Task<bool> AddDirector(MovieMetadata metadata, Director director) =>
|
||||
_dbConnection.ExecuteAsync(
|
||||
"INSERT INTO Director (Name, MovieMetadataId) VALUES (@Name, @MetadataId)",
|
||||
new { director.Name, MetadataId = metadata.Id }).Map(result => result > 0);
|
||||
|
||||
public Task<bool> RemoveWriter(Writer writer) =>
|
||||
_dbConnection.ExecuteAsync("DELETE FROM Writer WHERE Id = @WriterId", new { WriterId = writer.Id })
|
||||
.Map(result => result > 0);
|
||||
|
||||
public Task<bool> AddWriter(MovieMetadata metadata, Writer writer) =>
|
||||
_dbConnection.ExecuteAsync(
|
||||
"INSERT INTO Writer (Name, MovieMetadataId) VALUES (@Name, @MetadataId)",
|
||||
new { writer.Name, MetadataId = metadata.Id }).Map(result => result > 0);
|
||||
|
||||
private static async Task<Either<BaseError, MediaItemScanResult<Movie>>> AddMovie(
|
||||
TvContext dbContext,
|
||||
int libraryPathId,
|
||||
|
||||
@@ -41,6 +41,10 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
.ThenInclude(mm => mm.Studios)
|
||||
.Include(mi => (mi as Movie).MovieMetadata)
|
||||
.ThenInclude(mm => mm.Actors)
|
||||
.Include(mi => (mi as Movie).MovieMetadata)
|
||||
.ThenInclude(mm => mm.Directors)
|
||||
.Include(mi => (mi as Movie).MovieMetadata)
|
||||
.ThenInclude(mm => mm.Writers)
|
||||
.Include(mi => (mi as Movie).MediaVersions)
|
||||
.ThenInclude(mm => mm.Streams)
|
||||
.Include(mi => (mi as Show).ShowMetadata)
|
||||
|
||||
@@ -230,16 +230,20 @@ namespace ErsatzTV.Infrastructure.Emby
|
||||
|
||||
var metadata = new MovieMetadata
|
||||
{
|
||||
MetadataKind = MetadataKind.External,
|
||||
Title = item.Name,
|
||||
SortTitle = _fallbackMetadataProvider.GetSortTitle(item.Name),
|
||||
Plot = item.Overview,
|
||||
Year = item.ProductionYear,
|
||||
Tagline = Optional(item.Taglines).Flatten().HeadOrNone().IfNone(string.Empty),
|
||||
DateAdded = dateAdded,
|
||||
ContentRating = item.OfficialRating,
|
||||
Genres = Optional(item.Genres).Flatten().Map(g => new Genre { Name = g }).ToList(),
|
||||
Tags = Optional(item.Tags).Flatten().Map(t => new Tag { Name = t }).ToList(),
|
||||
Studios = Optional(item.Studios).Flatten().Map(s => new Studio { Name = s.Name }).ToList(),
|
||||
Actors = Optional(item.People).Flatten().Map(r => ProjectToModel(r, dateAdded)).ToList(),
|
||||
Actors = Optional(item.People).Flatten().Collect(r => ProjectToActor(r, dateAdded)).ToList(),
|
||||
Directors = Optional(item.People).Flatten().Collect(r => ProjectToDirector(r)).ToList(),
|
||||
Writers = Optional(item.People).Flatten().Collect(r => ProjectToWriter(r)).ToList(),
|
||||
Artwork = new List<Artwork>()
|
||||
};
|
||||
|
||||
@@ -279,8 +283,13 @@ namespace ErsatzTV.Infrastructure.Emby
|
||||
return metadata;
|
||||
}
|
||||
|
||||
private Actor ProjectToModel(EmbyPersonResponse person, DateTime dateAdded)
|
||||
private Option<Actor> ProjectToActor(EmbyPersonResponse person, DateTime dateAdded)
|
||||
{
|
||||
if (person.Type?.ToLowerInvariant() != "actor")
|
||||
{
|
||||
return None;
|
||||
}
|
||||
|
||||
var actor = new Actor { Name = person.Name, Role = person.Role };
|
||||
if (!string.IsNullOrWhiteSpace(person.Id) && !string.IsNullOrWhiteSpace(person.PrimaryImageTag))
|
||||
{
|
||||
@@ -295,6 +304,26 @@ namespace ErsatzTV.Infrastructure.Emby
|
||||
return actor;
|
||||
}
|
||||
|
||||
private static Option<Director> ProjectToDirector(EmbyPersonResponse person)
|
||||
{
|
||||
if (person.Type?.ToLowerInvariant() != "director")
|
||||
{
|
||||
return None;
|
||||
}
|
||||
|
||||
return new Director { Name = person.Name };
|
||||
}
|
||||
|
||||
private static Option<Writer> ProjectToWriter(EmbyPersonResponse person)
|
||||
{
|
||||
if (person.Type?.ToLowerInvariant() != "writer")
|
||||
{
|
||||
return None;
|
||||
}
|
||||
|
||||
return new Writer { Name = person.Name };
|
||||
}
|
||||
|
||||
private Option<EmbyShow> ProjectToShow(EmbyLibraryItemResponse item)
|
||||
{
|
||||
try
|
||||
@@ -324,16 +353,18 @@ namespace ErsatzTV.Infrastructure.Emby
|
||||
|
||||
var metadata = new ShowMetadata
|
||||
{
|
||||
MetadataKind = MetadataKind.External,
|
||||
Title = item.Name,
|
||||
SortTitle = _fallbackMetadataProvider.GetSortTitle(item.Name),
|
||||
Plot = item.Overview,
|
||||
Year = item.ProductionYear,
|
||||
Tagline = Optional(item.Taglines).Flatten().HeadOrNone().IfNone(string.Empty),
|
||||
DateAdded = dateAdded,
|
||||
ContentRating = item.OfficialRating,
|
||||
Genres = Optional(item.Genres).Flatten().Map(g => new Genre { Name = g }).ToList(),
|
||||
Tags = Optional(item.Tags).Flatten().Map(t => new Tag { Name = t }).ToList(),
|
||||
Studios = Optional(item.Studios).Flatten().Map(s => new Studio { Name = s.Name }).ToList(),
|
||||
Actors = Optional(item.People).Flatten().Map(r => ProjectToModel(r, dateAdded)).ToList(),
|
||||
Actors = Optional(item.People).Flatten().Collect(r => ProjectToActor(r, dateAdded)).ToList(),
|
||||
Artwork = new List<Artwork>()
|
||||
};
|
||||
|
||||
@@ -359,6 +390,17 @@ namespace ErsatzTV.Infrastructure.Emby
|
||||
metadata.Artwork.Add(poster);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(item.ImageTags.Thumb))
|
||||
{
|
||||
var thumb = new Artwork
|
||||
{
|
||||
ArtworkKind = ArtworkKind.Thumbnail,
|
||||
Path = $"emby://Items/{item.Id}/Images/Thumb?tag={item.ImageTags.Thumb}",
|
||||
DateAdded = dateAdded
|
||||
};
|
||||
metadata.Artwork.Add(thumb);
|
||||
}
|
||||
|
||||
if (item.BackdropImageTags.Any())
|
||||
{
|
||||
var fanArt = new Artwork
|
||||
@@ -382,6 +424,7 @@ namespace ErsatzTV.Infrastructure.Emby
|
||||
|
||||
var metadata = new SeasonMetadata
|
||||
{
|
||||
MetadataKind = MetadataKind.External,
|
||||
Title = item.Name,
|
||||
SortTitle = _fallbackMetadataProvider.GetSortTitle(item.Name),
|
||||
Year = item.ProductionYear,
|
||||
@@ -487,6 +530,7 @@ namespace ErsatzTV.Infrastructure.Emby
|
||||
|
||||
var metadata = new EpisodeMetadata
|
||||
{
|
||||
MetadataKind = MetadataKind.External,
|
||||
Title = item.Name,
|
||||
SortTitle = _fallbackMetadataProvider.GetSortTitle(item.Name),
|
||||
Plot = item.Overview,
|
||||
|
||||
@@ -28,9 +28,11 @@ namespace ErsatzTV.Infrastructure.Emby
|
||||
string parentId,
|
||||
[Query]
|
||||
string fields =
|
||||
"Path,Genres,Tags,DateCreated,Etag,Overview,Taglines,Studios,People,ProductionYear,PremiereDate,MediaSources",
|
||||
"Path,Genres,Tags,DateCreated,Etag,Overview,Taglines,Studios,People,ProductionYear,PremiereDate,MediaSources,OfficialRating",
|
||||
[Query]
|
||||
string includeItemTypes = "Movie");
|
||||
string includeItemTypes = "Movie",
|
||||
[Query]
|
||||
bool recursive = true);
|
||||
|
||||
[Get("/Items")]
|
||||
public Task<EmbyLibraryItemsResponse> GetShowLibraryItems(
|
||||
@@ -40,9 +42,11 @@ namespace ErsatzTV.Infrastructure.Emby
|
||||
string parentId,
|
||||
[Query]
|
||||
string fields =
|
||||
"Path,Genres,Tags,DateCreated,Etag,Overview,Taglines,Studios,People,ProductionYear,PremiereDate,MediaSources",
|
||||
"Path,Genres,Tags,DateCreated,Etag,Overview,Taglines,Studios,People,ProductionYear,PremiereDate,MediaSources,OfficialRating",
|
||||
[Query]
|
||||
string includeItemTypes = "Series");
|
||||
string includeItemTypes = "Series",
|
||||
[Query]
|
||||
bool recursive = true);
|
||||
|
||||
[Get("/Items")]
|
||||
public Task<EmbyLibraryItemsResponse> GetSeasonLibraryItems(
|
||||
@@ -53,7 +57,9 @@ namespace ErsatzTV.Infrastructure.Emby
|
||||
[Query]
|
||||
string fields = "Path,DateCreated,Etag,Taglines",
|
||||
[Query]
|
||||
string includeItemTypes = "Season");
|
||||
string includeItemTypes = "Season",
|
||||
[Query]
|
||||
bool recursive = true);
|
||||
|
||||
[Get("/Items")]
|
||||
public Task<EmbyLibraryItemsResponse> GetEpisodeLibraryItems(
|
||||
@@ -64,6 +70,8 @@ namespace ErsatzTV.Infrastructure.Emby
|
||||
[Query]
|
||||
string fields = "Path,DateCreated,Etag,Overview,ProductionYear,PremiereDate,MediaSources,LocationType",
|
||||
[Query]
|
||||
string includeItemTypes = "Episode");
|
||||
string includeItemTypes = "Episode",
|
||||
[Query]
|
||||
bool recursive = true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,5 +3,6 @@
|
||||
public class EmbyImageTagsResponse
|
||||
{
|
||||
public string Primary { get; set; }
|
||||
public string Thumb { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ namespace ErsatzTV.Infrastructure.Emby.Models
|
||||
public string Id { get; set; }
|
||||
public string Etag { get; set; }
|
||||
public string Path { get; set; }
|
||||
public string OfficialRating { get; set; }
|
||||
public DateTimeOffset DateCreated { get; set; }
|
||||
public long RunTimeTicks { get; set; }
|
||||
public List<string> Genres { get; set; }
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
<TargetFramework>net5.0</TargetFramework>
|
||||
<GenerateRuntimeConfigurationFiles>true</GenerateRuntimeConfigurationFiles>
|
||||
<NoWarn>VSTHRD200</NoWarn>
|
||||
<DebugType>embedded</DebugType>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -26,7 +26,7 @@ namespace ErsatzTV.Infrastructure.Images
|
||||
public async Task<Either<BaseError, byte[]>> ResizeImage(byte[] imageBuffer, int height)
|
||||
{
|
||||
await using var inStream = new MemoryStream(imageBuffer);
|
||||
using var image = await Image.LoadAsync(inStream);
|
||||
using Image image = await Image.LoadAsync(inStream);
|
||||
|
||||
var size = new Size { Height = height };
|
||||
|
||||
@@ -50,7 +50,7 @@ namespace ErsatzTV.Infrastructure.Images
|
||||
{
|
||||
byte[] hash = Crypto.ComputeHash(imageBuffer);
|
||||
string hex = BitConverter.ToString(hash).Replace("-", string.Empty);
|
||||
string subfolder = hex.Substring(0, 2);
|
||||
string subfolder = hex[..2];
|
||||
string baseFolder = artworkKind switch
|
||||
{
|
||||
ArtworkKind.Poster => Path.Combine(FileSystemLayout.PosterCacheFolder, subfolder),
|
||||
@@ -82,7 +82,7 @@ namespace ErsatzTV.Infrastructure.Images
|
||||
var filenameKey = $"{path}:{_localFileSystem.GetLastWriteTime(path).ToFileTimeUtc()}";
|
||||
byte[] hash = Crypto.ComputeHash(Encoding.UTF8.GetBytes(filenameKey));
|
||||
string hex = BitConverter.ToString(hash).Replace("-", string.Empty);
|
||||
string subfolder = hex.Substring(0, 2);
|
||||
string subfolder = hex[..2];
|
||||
string baseFolder = artworkKind switch
|
||||
{
|
||||
ArtworkKind.Poster => Path.Combine(FileSystemLayout.PosterCacheFolder, subfolder),
|
||||
@@ -102,5 +102,23 @@ namespace ErsatzTV.Infrastructure.Images
|
||||
return BaseError.New(ex.ToString());
|
||||
}
|
||||
}
|
||||
|
||||
public string GetPathForImage(string fileName, ArtworkKind artworkKind, Option<int> maybeMaxHeight)
|
||||
{
|
||||
string subfolder = maybeMaxHeight.Match(
|
||||
maxHeight => Path.Combine(maxHeight.ToString(), fileName[..2]),
|
||||
() => fileName[..2]);
|
||||
|
||||
string baseFolder = artworkKind switch
|
||||
{
|
||||
ArtworkKind.Poster => Path.Combine(FileSystemLayout.PosterCacheFolder, subfolder),
|
||||
ArtworkKind.Thumbnail => Path.Combine(FileSystemLayout.ThumbnailCacheFolder, subfolder),
|
||||
ArtworkKind.Logo => Path.Combine(FileSystemLayout.LogoCacheFolder, subfolder),
|
||||
ArtworkKind.FanArt => Path.Combine(FileSystemLayout.FanArtCacheFolder, subfolder),
|
||||
_ => FileSystemLayout.LegacyImageCacheFolder
|
||||
};
|
||||
|
||||
return Path.Combine(baseFolder, fileName);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,9 +34,11 @@ namespace ErsatzTV.Infrastructure.Jellyfin
|
||||
[Query]
|
||||
string parentId,
|
||||
[Query]
|
||||
string fields = "Path,Genres,Tags,DateCreated,Etag,Overview,Taglines,Studios,People",
|
||||
string fields = "Path,Genres,Tags,DateCreated,Etag,Overview,Taglines,Studios,People,OfficialRating",
|
||||
[Query]
|
||||
string includeItemTypes = "Movie");
|
||||
string includeItemTypes = "Movie",
|
||||
[Query]
|
||||
bool recursive = true);
|
||||
|
||||
[Get("/Items")]
|
||||
public Task<JellyfinLibraryItemsResponse> GetShowLibraryItems(
|
||||
@@ -47,9 +49,11 @@ namespace ErsatzTV.Infrastructure.Jellyfin
|
||||
[Query]
|
||||
string parentId,
|
||||
[Query]
|
||||
string fields = "Path,Genres,Tags,DateCreated,Etag,Overview,Taglines,Studios,People",
|
||||
string fields = "Path,Genres,Tags,DateCreated,Etag,Overview,Taglines,Studios,People,OfficialRating",
|
||||
[Query]
|
||||
string includeItemTypes = "Series");
|
||||
string includeItemTypes = "Series",
|
||||
[Query]
|
||||
bool recursive = true);
|
||||
|
||||
[Get("/Items")]
|
||||
public Task<JellyfinLibraryItemsResponse> GetSeasonLibraryItems(
|
||||
@@ -62,7 +66,9 @@ namespace ErsatzTV.Infrastructure.Jellyfin
|
||||
[Query]
|
||||
string fields = "Path,DateCreated,Etag,Taglines",
|
||||
[Query]
|
||||
string includeItemTypes = "Season");
|
||||
string includeItemTypes = "Season",
|
||||
[Query]
|
||||
bool recursive = true);
|
||||
|
||||
[Get("/Items")]
|
||||
public Task<JellyfinLibraryItemsResponse> GetEpisodeLibraryItems(
|
||||
@@ -75,6 +81,8 @@ namespace ErsatzTV.Infrastructure.Jellyfin
|
||||
[Query]
|
||||
string fields = "Path,DateCreated,Etag,Overview",
|
||||
[Query]
|
||||
string includeItemTypes = "Episode");
|
||||
string includeItemTypes = "Episode",
|
||||
[Query]
|
||||
bool recursive = true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -276,16 +276,20 @@ namespace ErsatzTV.Infrastructure.Jellyfin
|
||||
|
||||
var metadata = new MovieMetadata
|
||||
{
|
||||
MetadataKind = MetadataKind.External,
|
||||
Title = item.Name,
|
||||
SortTitle = _fallbackMetadataProvider.GetSortTitle(item.Name),
|
||||
Plot = item.Overview,
|
||||
Year = item.ProductionYear,
|
||||
Tagline = Optional(item.Taglines).Flatten().HeadOrNone().IfNone(string.Empty),
|
||||
DateAdded = dateAdded,
|
||||
ContentRating = item.OfficialRating,
|
||||
Genres = Optional(item.Genres).Flatten().Map(g => new Genre { Name = g }).ToList(),
|
||||
Tags = Optional(item.Tags).Flatten().Map(t => new Tag { Name = t }).ToList(),
|
||||
Studios = Optional(item.Studios).Flatten().Map(s => new Studio { Name = s.Name }).ToList(),
|
||||
Actors = Optional(item.People).Flatten().Map(r => ProjectToModel(r, dateAdded)).ToList(),
|
||||
Actors = Optional(item.People).Flatten().Collect(r => ProjectToActor(r, dateAdded)).ToList(),
|
||||
Directors = Optional(item.People).Flatten().Collect(r => ProjectToDirector(r)).ToList(),
|
||||
Writers = Optional(item.People).Flatten().Collect(r => ProjectToWriter(r)).ToList(),
|
||||
Artwork = new List<Artwork>()
|
||||
};
|
||||
|
||||
@@ -325,8 +329,13 @@ namespace ErsatzTV.Infrastructure.Jellyfin
|
||||
return metadata;
|
||||
}
|
||||
|
||||
private Actor ProjectToModel(JellyfinPersonResponse person, DateTime dateAdded)
|
||||
private static Option<Actor> ProjectToActor(JellyfinPersonResponse person, DateTime dateAdded)
|
||||
{
|
||||
if (person.Type?.ToLowerInvariant() != "actor")
|
||||
{
|
||||
return None;
|
||||
}
|
||||
|
||||
var actor = new Actor { Name = person.Name, Role = person.Role };
|
||||
if (!string.IsNullOrWhiteSpace(person.Id) && !string.IsNullOrWhiteSpace(person.PrimaryImageTag))
|
||||
{
|
||||
@@ -341,6 +350,26 @@ namespace ErsatzTV.Infrastructure.Jellyfin
|
||||
return actor;
|
||||
}
|
||||
|
||||
private static Option<Director> ProjectToDirector(JellyfinPersonResponse person)
|
||||
{
|
||||
if (person.Type?.ToLowerInvariant() != "director")
|
||||
{
|
||||
return None;
|
||||
}
|
||||
|
||||
return new Director { Name = person.Name };
|
||||
}
|
||||
|
||||
private static Option<Writer> ProjectToWriter(JellyfinPersonResponse person)
|
||||
{
|
||||
if (person.Type?.ToLowerInvariant() != "writer")
|
||||
{
|
||||
return None;
|
||||
}
|
||||
|
||||
return new Writer { Name = person.Name };
|
||||
}
|
||||
|
||||
private Option<JellyfinShow> ProjectToShow(JellyfinLibraryItemResponse item)
|
||||
{
|
||||
try
|
||||
@@ -375,16 +404,18 @@ namespace ErsatzTV.Infrastructure.Jellyfin
|
||||
|
||||
var metadata = new ShowMetadata
|
||||
{
|
||||
MetadataKind = MetadataKind.External,
|
||||
Title = item.Name,
|
||||
SortTitle = _fallbackMetadataProvider.GetSortTitle(item.Name),
|
||||
Plot = item.Overview,
|
||||
Year = item.ProductionYear,
|
||||
Tagline = Optional(item.Taglines).Flatten().HeadOrNone().IfNone(string.Empty),
|
||||
DateAdded = dateAdded,
|
||||
ContentRating = item.OfficialRating,
|
||||
Genres = Optional(item.Genres).Flatten().Map(g => new Genre { Name = g }).ToList(),
|
||||
Tags = Optional(item.Tags).Flatten().Map(t => new Tag { Name = t }).ToList(),
|
||||
Studios = Optional(item.Studios).Flatten().Map(s => new Studio { Name = s.Name }).ToList(),
|
||||
Actors = Optional(item.People).Flatten().Map(r => ProjectToModel(r, dateAdded)).ToList(),
|
||||
Actors = Optional(item.People).Flatten().Collect(r => ProjectToActor(r, dateAdded)).ToList(),
|
||||
Artwork = new List<Artwork>()
|
||||
};
|
||||
|
||||
@@ -410,6 +441,17 @@ namespace ErsatzTV.Infrastructure.Jellyfin
|
||||
metadata.Artwork.Add(poster);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(item.ImageTags.Thumb))
|
||||
{
|
||||
var thumb = new Artwork
|
||||
{
|
||||
ArtworkKind = ArtworkKind.Thumbnail,
|
||||
Path = $"jellyfin://Items/{item.Id}/Images/Thumb?tag={item.ImageTags.Thumb}",
|
||||
DateAdded = dateAdded
|
||||
};
|
||||
metadata.Artwork.Add(thumb);
|
||||
}
|
||||
|
||||
if (item.BackdropImageTags.Any())
|
||||
{
|
||||
var fanArt = new Artwork
|
||||
@@ -438,6 +480,7 @@ namespace ErsatzTV.Infrastructure.Jellyfin
|
||||
|
||||
var metadata = new SeasonMetadata
|
||||
{
|
||||
MetadataKind = MetadataKind.External,
|
||||
Title = item.Name,
|
||||
SortTitle = _fallbackMetadataProvider.GetSortTitle(item.Name),
|
||||
Year = item.ProductionYear,
|
||||
@@ -483,7 +526,7 @@ namespace ErsatzTV.Infrastructure.Jellyfin
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Error projecting Jellyfin show");
|
||||
_logger.LogWarning(ex, "Error projecting Jellyfin season");
|
||||
return None;
|
||||
}
|
||||
}
|
||||
@@ -531,7 +574,7 @@ namespace ErsatzTV.Infrastructure.Jellyfin
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Error projecting Jellyfin movie");
|
||||
_logger.LogWarning(ex, "Error projecting Jellyfin episode");
|
||||
return None;
|
||||
}
|
||||
}
|
||||
@@ -543,6 +586,7 @@ namespace ErsatzTV.Infrastructure.Jellyfin
|
||||
|
||||
var metadata = new EpisodeMetadata
|
||||
{
|
||||
MetadataKind = MetadataKind.External,
|
||||
Title = item.Name,
|
||||
SortTitle = _fallbackMetadataProvider.GetSortTitle(item.Name),
|
||||
Plot = item.Overview,
|
||||
|
||||
@@ -3,5 +3,6 @@
|
||||
public class JellyfinImageTagsResponse
|
||||
{
|
||||
public string Primary { get; set; }
|
||||
public string Thumb { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ namespace ErsatzTV.Infrastructure.Jellyfin.Models
|
||||
public string Id { get; set; }
|
||||
public string Etag { get; set; }
|
||||
public string Path { get; set; }
|
||||
public string OfficialRating { get; set; }
|
||||
public DateTimeOffset DateCreated { get; set; }
|
||||
public long RunTimeTicks { get; set; }
|
||||
public List<string> Genres { get; set; }
|
||||
|
||||
+2717
File diff suppressed because it is too large
Load Diff
+50
@@ -0,0 +1,50 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
namespace ErsatzTV.Infrastructure.Migrations
|
||||
{
|
||||
public partial class Add_MovieMetadataShowMetadataContentRating : Migration
|
||||
{
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
// local and plex
|
||||
migrationBuilder.Sql("UPDATE MovieMetadata SET DateUpdated = '0001-01-01 00:00:00'");
|
||||
migrationBuilder.Sql("UPDATE ShowMetadata SET DateUpdated = '0001-01-01 00:00:00'");
|
||||
migrationBuilder.Sql(
|
||||
@"UPDATE LibraryFolder SET Etag = NULL WHERE LibraryPathId IN
|
||||
(SELECT LibraryPathId FROM LibraryPath LP
|
||||
INNER JOIN Library L on LP.LibraryId = L.Id
|
||||
WHERE L.MediaKind = 1)");
|
||||
|
||||
// emby
|
||||
migrationBuilder.Sql("UPDATE EmbyMovie SET Etag = NULL");
|
||||
migrationBuilder.Sql("UPDATE EmbyShow SET Etag = NULL");
|
||||
|
||||
// jellyfin
|
||||
migrationBuilder.Sql("UPDATE JellyfinMovie SET Etag = NULL");
|
||||
migrationBuilder.Sql("UPDATE JellyfinShow SET Etag = NULL");
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
"ContentRating",
|
||||
"ShowMetadata",
|
||||
"TEXT",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
"ContentRating",
|
||||
"MovieMetadata",
|
||||
"TEXT",
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
"ContentRating",
|
||||
"ShowMetadata");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
"ContentRating",
|
||||
"MovieMetadata");
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+2775
File diff suppressed because it is too large
Load Diff
+83
@@ -0,0 +1,83 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
namespace ErsatzTV.Infrastructure.Migrations
|
||||
{
|
||||
public partial class Add_MovieMetadataDirectorsWriters : Migration
|
||||
{
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
// local and plex
|
||||
migrationBuilder.Sql("UPDATE MovieMetadata SET DateUpdated = '0001-01-01 00:00:00'");
|
||||
migrationBuilder.Sql(
|
||||
@"UPDATE LibraryFolder SET Etag = NULL WHERE LibraryPathId IN
|
||||
(SELECT LibraryPathId FROM LibraryPath LP
|
||||
INNER JOIN Library L on LP.LibraryId = L.Id
|
||||
WHERE L.MediaKind = 1)");
|
||||
|
||||
// emby
|
||||
migrationBuilder.Sql("UPDATE EmbyMovie SET Etag = NULL");
|
||||
|
||||
// jellyfin
|
||||
migrationBuilder.Sql("UPDATE JellyfinMovie SET Etag = NULL");
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
"Director",
|
||||
table => new
|
||||
{
|
||||
Id = table.Column<int>("INTEGER", nullable: false)
|
||||
.Annotation("Sqlite:Autoincrement", true),
|
||||
Name = table.Column<string>("TEXT", nullable: true),
|
||||
MovieMetadataId = table.Column<int>("INTEGER", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Director", x => x.Id);
|
||||
table.ForeignKey(
|
||||
"FK_Director_MovieMetadata_MovieMetadataId",
|
||||
x => x.MovieMetadataId,
|
||||
"MovieMetadata",
|
||||
"Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
"Writer",
|
||||
table => new
|
||||
{
|
||||
Id = table.Column<int>("INTEGER", nullable: false)
|
||||
.Annotation("Sqlite:Autoincrement", true),
|
||||
Name = table.Column<string>("TEXT", nullable: true),
|
||||
MovieMetadataId = table.Column<int>("INTEGER", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Writer", x => x.Id);
|
||||
table.ForeignKey(
|
||||
"FK_Writer_MovieMetadata_MovieMetadataId",
|
||||
x => x.MovieMetadataId,
|
||||
"MovieMetadata",
|
||||
"Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
"IX_Director_MovieMetadataId",
|
||||
"Director",
|
||||
"MovieMetadataId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
"IX_Writer_MovieMetadataId",
|
||||
"Writer",
|
||||
"MovieMetadataId");
|
||||
}
|
||||
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
"Director");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
"Writer");
|
||||
}
|
||||
}
|
||||
}
|
||||
+2775
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,38 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
namespace ErsatzTV.Infrastructure.Migrations
|
||||
{
|
||||
public partial class v0_0_40_FixMissingArtwork : Migration
|
||||
{
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
// local and plex
|
||||
migrationBuilder.Sql("UPDATE Artwork SET DateUpdated = '0001-01-01 00:00:00'");
|
||||
migrationBuilder.Sql("UPDATE MovieMetadata SET DateUpdated = '0001-01-01 00:00:00'");
|
||||
migrationBuilder.Sql("UPDATE ShowMetadata SET DateUpdated = '0001-01-01 00:00:00'");
|
||||
migrationBuilder.Sql("UPDATE SeasonMetadata SET DateUpdated = '0001-01-01 00:00:00'");
|
||||
migrationBuilder.Sql("UPDATE EpisodeMetadata SET DateUpdated = '0001-01-01 00:00:00'");
|
||||
migrationBuilder.Sql(
|
||||
@"UPDATE LibraryFolder SET Etag = NULL WHERE LibraryPathId IN
|
||||
(SELECT LibraryPathId FROM LibraryPath LP
|
||||
INNER JOIN Library L on LP.LibraryId = L.Id
|
||||
WHERE L.MediaKind = 1)");
|
||||
|
||||
// emby
|
||||
migrationBuilder.Sql("UPDATE EmbyMovie SET Etag = NULL");
|
||||
migrationBuilder.Sql("UPDATE EmbyShow SET Etag = NULL");
|
||||
migrationBuilder.Sql("UPDATE EmbySeason SET Etag = NULL");
|
||||
migrationBuilder.Sql("UPDATE EmbyEpisode SET Etag = NULL");
|
||||
|
||||
// jellyfin
|
||||
migrationBuilder.Sql("UPDATE JellyfinMovie SET Etag = NULL");
|
||||
migrationBuilder.Sql("UPDATE JellyfinShow SET Etag = NULL");
|
||||
migrationBuilder.Sql("UPDATE JellyfinSeason SET Etag = NULL");
|
||||
migrationBuilder.Sql("UPDATE JellyfinEpisode SET Etag = NULL");
|
||||
}
|
||||
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,7 @@
|
||||
namespace ErsatzTV.Infrastructure.Plex.Models
|
||||
{
|
||||
public class PlexDirectorResponse
|
||||
{
|
||||
public string Tag { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ namespace ErsatzTV.Infrastructure.Plex.Models
|
||||
{
|
||||
public string Key { get; set; }
|
||||
public string Title { get; set; }
|
||||
public string ContentRating { get; set; }
|
||||
public string Summary { get; set; }
|
||||
public int Year { get; set; }
|
||||
public string Tagline { get; set; }
|
||||
@@ -19,5 +20,7 @@ namespace ErsatzTV.Infrastructure.Plex.Models
|
||||
public List<PlexMediaResponse> Media { get; set; }
|
||||
public List<PlexGenreResponse> Genre { get; set; }
|
||||
public List<PlexRoleResponse> Role { get; set; }
|
||||
public List<PlexDirectorResponse> Director { get; set; }
|
||||
public List<PlexWriterResponse> Writer { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace ErsatzTV.Infrastructure.Plex.Models
|
||||
{
|
||||
public class PlexWriterResponse
|
||||
{
|
||||
public string Tag { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -250,18 +250,22 @@ namespace ErsatzTV.Infrastructure.Plex
|
||||
|
||||
var metadata = new MovieMetadata
|
||||
{
|
||||
MetadataKind = MetadataKind.External,
|
||||
Title = response.Title,
|
||||
SortTitle = _fallbackMetadataProvider.GetSortTitle(response.Title),
|
||||
Plot = response.Summary,
|
||||
Year = response.Year,
|
||||
Tagline = response.Tagline,
|
||||
ContentRating = response.ContentRating,
|
||||
DateAdded = dateAdded,
|
||||
DateUpdated = lastWriteTime,
|
||||
Genres = Optional(response.Genre).Flatten().Map(g => new Genre { Name = g.Tag }).ToList(),
|
||||
Tags = new List<Tag>(),
|
||||
Studios = new List<Studio>(),
|
||||
Actors = Optional(response.Role).Flatten().Map(r => ProjectToModel(r, dateAdded, lastWriteTime))
|
||||
.ToList()
|
||||
.ToList(),
|
||||
Directors = Optional(response.Director).Flatten().Map(d => new Director { Name = d.Tag }).ToList(),
|
||||
Writers = Optional(response.Writer).Flatten().Map(w => new Writer { Name = w.Tag }).ToList()
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(response.Studio))
|
||||
@@ -398,11 +402,13 @@ namespace ErsatzTV.Infrastructure.Plex
|
||||
|
||||
var metadata = new ShowMetadata
|
||||
{
|
||||
MetadataKind = MetadataKind.External,
|
||||
Title = response.Title,
|
||||
SortTitle = _fallbackMetadataProvider.GetSortTitle(response.Title),
|
||||
Plot = response.Summary,
|
||||
Year = response.Year,
|
||||
Tagline = response.Tagline,
|
||||
ContentRating = response.ContentRating,
|
||||
DateAdded = dateAdded,
|
||||
DateUpdated = lastWriteTime,
|
||||
Genres = Optional(response.Genre).Flatten().Map(g => new Genre { Name = g.Tag }).ToList(),
|
||||
@@ -462,6 +468,7 @@ namespace ErsatzTV.Infrastructure.Plex
|
||||
|
||||
var metadata = new SeasonMetadata
|
||||
{
|
||||
MetadataKind = MetadataKind.External,
|
||||
Title = response.Title,
|
||||
SortTitle = _fallbackMetadataProvider.GetSortTitle(response.Title),
|
||||
Year = response.Year,
|
||||
@@ -518,6 +525,7 @@ namespace ErsatzTV.Infrastructure.Plex
|
||||
|
||||
var metadata = new EpisodeMetadata
|
||||
{
|
||||
MetadataKind = MetadataKind.External,
|
||||
Title = response.Title,
|
||||
SortTitle = _fallbackMetadataProvider.GetSortTitle(response.Title),
|
||||
Plot = response.Summary,
|
||||
|
||||
@@ -11,6 +11,9 @@ using ErsatzTV.Core.Interfaces.Search;
|
||||
using ErsatzTV.Core.Search;
|
||||
using LanguageExt;
|
||||
using LanguageExt.UnsafeValueAccess;
|
||||
using Lucene.Net.Analysis;
|
||||
using Lucene.Net.Analysis.Core;
|
||||
using Lucene.Net.Analysis.Miscellaneous;
|
||||
using Lucene.Net.Analysis.Standard;
|
||||
using Lucene.Net.Documents;
|
||||
using Lucene.Net.Index;
|
||||
@@ -45,6 +48,9 @@ namespace ErsatzTV.Infrastructure.Search
|
||||
private const string StyleField = "style";
|
||||
private const string MoodField = "mood";
|
||||
private const string ActorField = "actor";
|
||||
private const string ContentRatingField = "content_rating";
|
||||
private const string DirectorField = "director";
|
||||
private const string WriterField = "writer";
|
||||
|
||||
private const string MovieType = "movie";
|
||||
private const string ShowType = "show";
|
||||
@@ -65,7 +71,7 @@ namespace ErsatzTV.Infrastructure.Search
|
||||
_initialized = false;
|
||||
}
|
||||
|
||||
public int Version => 9;
|
||||
public int Version => 12;
|
||||
|
||||
public Task<bool> Initialize(ILocalFileSystem localFileSystem)
|
||||
{
|
||||
@@ -86,6 +92,8 @@ namespace ErsatzTV.Infrastructure.Search
|
||||
|
||||
public async Task<Unit> Rebuild(ISearchRepository searchRepository, List<int> itemIds)
|
||||
{
|
||||
_writer.DeleteAll();
|
||||
|
||||
foreach (int id in itemIds)
|
||||
{
|
||||
Option<MediaItem> maybeMediaItem = await searchRepository.GetItemToIndex(id);
|
||||
@@ -162,9 +170,14 @@ namespace ErsatzTV.Infrastructure.Search
|
||||
var searcher = new IndexSearcher(reader);
|
||||
int hitsLimit = limit == 0 ? searcher.IndexReader.MaxDoc : skip + limit;
|
||||
using var analyzer = new StandardAnalyzer(AppLuceneVersion);
|
||||
var customAnalyzers = new Dictionary<string, Analyzer>
|
||||
{
|
||||
{ ContentRatingField, new KeywordAnalyzer() }
|
||||
};
|
||||
using var analyzerWrapper = new PerFieldAnalyzerWrapper(analyzer, customAnalyzers);
|
||||
QueryParser parser = !string.IsNullOrWhiteSpace(searchField)
|
||||
? new QueryParser(AppLuceneVersion, searchField, analyzer)
|
||||
: new MultiFieldQueryParser(AppLuceneVersion, new[] { TitleField }, analyzer);
|
||||
? new QueryParser(AppLuceneVersion, searchField, analyzerWrapper)
|
||||
: new MultiFieldQueryParser(AppLuceneVersion, new[] { TitleField }, analyzerWrapper);
|
||||
parser.AllowLeadingWildcard = true;
|
||||
Query query = ParseQuery(searchQuery, parser);
|
||||
var filter = new DuplicateFilter(TitleAndYearField);
|
||||
@@ -266,6 +279,15 @@ namespace ErsatzTV.Infrastructure.Search
|
||||
|
||||
AddLanguages(doc, movie.MediaVersions);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(metadata.ContentRating))
|
||||
{
|
||||
foreach (string contentRating in (metadata.ContentRating ?? string.Empty).Split("/")
|
||||
.Map(x => x.Trim()).Where(x => !string.IsNullOrWhiteSpace(x)))
|
||||
{
|
||||
doc.Add(new StringField(ContentRatingField, contentRating, Field.Store.NO));
|
||||
}
|
||||
}
|
||||
|
||||
if (metadata.ReleaseDate.HasValue)
|
||||
{
|
||||
doc.Add(
|
||||
@@ -300,6 +322,16 @@ namespace ErsatzTV.Infrastructure.Search
|
||||
doc.Add(new TextField(ActorField, actor.Name, Field.Store.NO));
|
||||
}
|
||||
|
||||
foreach (Director director in metadata.Directors)
|
||||
{
|
||||
doc.Add(new TextField(DirectorField, director.Name, Field.Store.NO));
|
||||
}
|
||||
|
||||
foreach (Writer writer in metadata.Writers)
|
||||
{
|
||||
doc.Add(new TextField(WriterField, writer.Name, Field.Store.NO));
|
||||
}
|
||||
|
||||
_writer.UpdateDocument(new Term(IdField, movie.Id.ToString()), doc);
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -368,6 +400,15 @@ namespace ErsatzTV.Infrastructure.Search
|
||||
doc.Add(new TextField(LanguageField, cultureInfo.EnglishName, Field.Store.NO));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(metadata.ContentRating))
|
||||
{
|
||||
foreach (string contentRating in (metadata.ContentRating ?? string.Empty).Split("/")
|
||||
.Map(x => x.Trim()).Where(x => !string.IsNullOrWhiteSpace(x)))
|
||||
{
|
||||
doc.Add(new StringField(ContentRatingField, contentRating, Field.Store.NO));
|
||||
}
|
||||
}
|
||||
|
||||
if (metadata.ReleaseDate.HasValue)
|
||||
{
|
||||
doc.Add(
|
||||
|
||||
@@ -2,12 +2,19 @@
|
||||
using System.IO;
|
||||
using System.Net.Http;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Application.Emby;
|
||||
using ErsatzTV.Application.Emby.Queries;
|
||||
using ErsatzTV.Application.Images;
|
||||
using ErsatzTV.Application.Images.Queries;
|
||||
using ErsatzTV.Application.Jellyfin;
|
||||
using ErsatzTV.Application.Jellyfin.Queries;
|
||||
using ErsatzTV.Application.Plex;
|
||||
using ErsatzTV.Application.Plex.Queries;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Emby;
|
||||
using ErsatzTV.Core.Jellyfin;
|
||||
using Flurl;
|
||||
using LanguageExt;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
@@ -32,21 +39,50 @@ namespace ErsatzTV.Controllers
|
||||
[HttpGet("/artwork/posters/{fileName}")]
|
||||
public async Task<IActionResult> GetPoster(string fileName)
|
||||
{
|
||||
Either<BaseError, ImageViewModel> imageContents =
|
||||
await _mediator.Send(new GetImageContents(fileName, ArtworkKind.Poster, 440));
|
||||
return imageContents.Match<IActionResult>(
|
||||
Either<BaseError, CachedImagePathViewModel> cachedImagePath =
|
||||
await _mediator.Send(new GetCachedImagePath(fileName, ArtworkKind.Poster, 440));
|
||||
return cachedImagePath.Match<IActionResult>(
|
||||
Left: _ => new NotFoundResult(),
|
||||
Right: r => new FileContentResult(r.Contents, r.MimeType));
|
||||
Right: r => new PhysicalFileResult(r.FileName, r.MimeType));
|
||||
}
|
||||
|
||||
[HttpGet("/artwork/fanart/{fileName}")]
|
||||
public async Task<IActionResult> GetFanArt(string fileName)
|
||||
{
|
||||
Either<BaseError, ImageViewModel> imageContents =
|
||||
await _mediator.Send(new GetImageContents(fileName, ArtworkKind.FanArt));
|
||||
return imageContents.Match<IActionResult>(
|
||||
Either<BaseError, CachedImagePathViewModel> cachedImagePath =
|
||||
await _mediator.Send(new GetCachedImagePath(fileName, ArtworkKind.FanArt));
|
||||
return cachedImagePath.Match<IActionResult>(
|
||||
Left: _ => new NotFoundResult(),
|
||||
Right: r => new FileContentResult(r.Contents, r.MimeType));
|
||||
Right: r => new PhysicalFileResult(r.FileName, r.MimeType));
|
||||
}
|
||||
|
||||
|
||||
[HttpGet("/iptv/artwork/posters/jellyfin/{*path}")]
|
||||
[HttpGet("/artwork/posters/jellyfin/{*path}")]
|
||||
[HttpGet("/iptv/artwork/thumbnails/jellyfin/{*path}")]
|
||||
[HttpGet("/artwork/thumbnails/jellyfin/{*path}")]
|
||||
public Task<IActionResult> GetJellyfin(string path)
|
||||
{
|
||||
if (Request.QueryString.HasValue)
|
||||
{
|
||||
path += Request.QueryString.Value;
|
||||
}
|
||||
|
||||
return GetJellyfinArtwork(path);
|
||||
}
|
||||
|
||||
[HttpGet("/iptv/artwork/posters/emby/{*path}")]
|
||||
[HttpGet("/artwork/posters/emby/{*path}")]
|
||||
[HttpGet("/iptv/artwork/thumbnails/emby/{*path}")]
|
||||
[HttpGet("/artwork/thumbnails/emby/{*path}")]
|
||||
public Task<IActionResult> GetEmby(string path)
|
||||
{
|
||||
if (Request.QueryString.HasValue)
|
||||
{
|
||||
path += Request.QueryString.Value;
|
||||
}
|
||||
|
||||
return GetEmbyArtwork(path);
|
||||
}
|
||||
|
||||
[HttpGet("/iptv/artwork/posters/plex/{plexMediaSourceId}/{*path}")]
|
||||
@@ -68,14 +104,15 @@ namespace ErsatzTV.Controllers
|
||||
plexMediaSourceId,
|
||||
$"photo/:/transcode?url=/{path}&height=220&width=392&minSize=1&upscale=0");
|
||||
|
||||
[HttpGet("/iptv/artwork/thumbnails/{fileName}")]
|
||||
[HttpGet("/artwork/thumbnails/{fileName}")]
|
||||
public async Task<IActionResult> GetThumbnail(string fileName)
|
||||
{
|
||||
Either<BaseError, ImageViewModel> imageContents =
|
||||
await _mediator.Send(new GetImageContents(fileName, ArtworkKind.Thumbnail, 220));
|
||||
return imageContents.Match<IActionResult>(
|
||||
Either<BaseError, CachedImagePathViewModel> cachedImagePath =
|
||||
await _mediator.Send(new GetCachedImagePath(fileName, ArtworkKind.Thumbnail, 220));
|
||||
return cachedImagePath.Match<IActionResult>(
|
||||
Left: _ => new NotFoundResult(),
|
||||
Right: r => new FileContentResult(r.Contents, r.MimeType));
|
||||
Right: r => new PhysicalFileResult(r.FileName, r.MimeType));
|
||||
}
|
||||
|
||||
private async Task<IActionResult> GetPlexArtwork(int plexMediaSourceId, string transcodePath)
|
||||
@@ -101,5 +138,51 @@ namespace ErsatzTV.Controllers
|
||||
response.Content.Headers.ContentType?.MediaType ?? "image/jpeg");
|
||||
});
|
||||
}
|
||||
|
||||
private async Task<IActionResult> GetJellyfinArtwork(string path)
|
||||
{
|
||||
Either<BaseError, JellyfinConnectionParametersViewModel> connectionParameters =
|
||||
await _mediator.Send(new GetJellyfinConnectionParameters());
|
||||
|
||||
return await connectionParameters.Match<Task<IActionResult>>(
|
||||
Left: _ => new NotFoundResult().AsTask<IActionResult>(),
|
||||
Right: async vm =>
|
||||
{
|
||||
HttpClient client = _httpClientFactory.CreateClient();
|
||||
|
||||
Url fullPath = JellyfinUrl.ForArtwork(vm.Address, path);
|
||||
HttpResponseMessage response = await client.GetAsync(
|
||||
fullPath,
|
||||
HttpCompletionOption.ResponseHeadersRead);
|
||||
Stream stream = await response.Content.ReadAsStreamAsync();
|
||||
|
||||
return new FileStreamResult(
|
||||
stream,
|
||||
response.Content.Headers.ContentType?.MediaType ?? "image/jpeg");
|
||||
});
|
||||
}
|
||||
|
||||
private async Task<IActionResult> GetEmbyArtwork(string path)
|
||||
{
|
||||
Either<BaseError, EmbyConnectionParametersViewModel> connectionParameters =
|
||||
await _mediator.Send(new GetEmbyConnectionParameters());
|
||||
|
||||
return await connectionParameters.Match<Task<IActionResult>>(
|
||||
Left: _ => new NotFoundResult().AsTask<IActionResult>(),
|
||||
Right: async vm =>
|
||||
{
|
||||
HttpClient client = _httpClientFactory.CreateClient();
|
||||
|
||||
Url fullPath = EmbyUrl.ForArtwork(vm.Address, path);
|
||||
HttpResponseMessage response = await client.GetAsync(
|
||||
fullPath,
|
||||
HttpCompletionOption.ResponseHeadersRead);
|
||||
Stream stream = await response.Content.ReadAsStreamAsync();
|
||||
|
||||
return new FileStreamResult(
|
||||
stream,
|
||||
response.Content.Headers.ContentType?.MediaType ?? "image/jpeg");
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,11 +67,11 @@ namespace ErsatzTV.Controllers
|
||||
[HttpGet("iptv/logos/{fileName}")]
|
||||
public async Task<IActionResult> GetImage(string fileName)
|
||||
{
|
||||
Either<BaseError, ImageViewModel> imageContents =
|
||||
await _mediator.Send(new GetImageContents(fileName, ArtworkKind.Logo));
|
||||
return imageContents.Match<IActionResult>(
|
||||
Either<BaseError, CachedImagePathViewModel> cachedImagePath =
|
||||
await _mediator.Send(new GetCachedImagePath(fileName, ArtworkKind.Logo));
|
||||
return cachedImagePath.Match<IActionResult>(
|
||||
Left: _ => new NotFoundResult(),
|
||||
Right: r => new FileContentResult(r.Contents, r.MimeType));
|
||||
Right: r => new PhysicalFileResult(r.FileName, r.MimeType));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
<TargetFramework>net5.0</TargetFramework>
|
||||
<NoWarn>VSTHRD200</NoWarn>
|
||||
<IncludeAllContentForSelfExtract>true</IncludeAllContentForSelfExtract>
|
||||
<DebugType>embedded</DebugType>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
@@ -33,7 +34,7 @@
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="MudBlazor" Version="5.0.9" />
|
||||
<PackageReference Include="MudBlazor" Version="5.0.10" />
|
||||
<PackageReference Include="PPioli.FluentValidation.Blazor" Version="5.0.0" />
|
||||
<PackageReference Include="Refit.HttpClientFactory" Version="6.0.38" />
|
||||
<PackageReference Include="Serilog" Version="2.10.0" />
|
||||
@@ -51,12 +52,9 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Update="Resources\background.png">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
</None>
|
||||
<None Update="Resources\Roboto-Regular.ttf">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
</None>
|
||||
<EmbeddedResource Include="Resources\background.png" />
|
||||
<EmbeddedResource Include="Resources\ErsatzTV.png" />
|
||||
<EmbeddedResource Include="Resources\Roboto-Regular.ttf" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
{
|
||||
<img class="mud-elevation-2 mr-6"
|
||||
style="border-radius: 4px; flex-shrink: 0; height: 220px; width: 220px"
|
||||
src="@($"/artwork/thumbnails/{_artist.Thumbnail}")" alt="artist thumnail"/>
|
||||
src="@($"/artwork/thumbnails/{_artist.Thumbnail}")" alt="artist thumbnail"/>
|
||||
}
|
||||
<div style="display: flex; flex-direction: column; height: 100%">
|
||||
<MudText Typo="Typo.h2" Class="media-item-title">@_artist.Name</MudText>
|
||||
|
||||
@@ -63,6 +63,18 @@
|
||||
</div>
|
||||
<MudCard Class="mb-6">
|
||||
<MudCardContent>
|
||||
@if (_sortedContentRatings.Any())
|
||||
{
|
||||
<div style="display: flex; flex-direction: row; flex-wrap: wrap">
|
||||
<MudText GutterBottom="true">Content Ratings: </MudText>
|
||||
<MudLink Href="@($"/search?query=content_rating%3a%22{Uri.EscapeDataString(_sortedContentRatings.Head())}%22")">@_sortedContentRatings.Head()</MudLink>
|
||||
@foreach (string contentRating in _sortedContentRatings.Skip(1))
|
||||
{
|
||||
<MudText>, </MudText>
|
||||
<MudLink Href="@($"/search?query=content_rating%3a%22{Uri.EscapeDataString(contentRating)}%22")">@contentRating</MudLink>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@if (_sortedLanguages.Any())
|
||||
{
|
||||
<div style="display: flex; flex-direction: row; flex-wrap: wrap">
|
||||
@@ -87,6 +99,30 @@
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@if (_sortedDirectors.Any())
|
||||
{
|
||||
<div style="display: flex; flex-direction: row; flex-wrap: wrap">
|
||||
<MudText GutterBottom="true">Directors: </MudText>
|
||||
<MudLink Href="@($"/search?query=director%3a%22{Uri.EscapeDataString(_sortedDirectors.Head())}%22")">@_sortedDirectors.Head()</MudLink>
|
||||
@foreach (string director in _sortedDirectors.Skip(1))
|
||||
{
|
||||
<MudText>, </MudText>
|
||||
<MudLink Href="@($"/search?query=director%3a%22{Uri.EscapeDataString(director)}%22")">@director</MudLink>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@if (_sortedWriters.Any())
|
||||
{
|
||||
<div style="display: flex; flex-direction: row; flex-wrap: wrap">
|
||||
<MudText GutterBottom="true">Writers: </MudText>
|
||||
<MudLink Href="@($"/search?query=writer%3a%22{Uri.EscapeDataString(_sortedWriters.Head())}%22")">@_sortedWriters.Head()</MudLink>
|
||||
@foreach (string writer in _sortedWriters.Skip(1))
|
||||
{
|
||||
<MudText>, </MudText>
|
||||
<MudLink Href="@($"/search?query=writer%3a%22{Uri.EscapeDataString(writer)}%22")">@writer</MudLink>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@if (_sortedGenres.Any())
|
||||
{
|
||||
<div style="display: flex; flex-direction: row; flex-wrap: wrap">
|
||||
@@ -136,7 +172,10 @@
|
||||
public int MovieId { get; set; }
|
||||
|
||||
private MovieViewModel _movie;
|
||||
private List<string> _sortedContentRatings = new();
|
||||
private List<CultureInfo> _sortedLanguages = new();
|
||||
private List<string> _sortedDirectors = new();
|
||||
private List<string> _sortedWriters = new();
|
||||
private List<string> _sortedStudios = new();
|
||||
private List<string> _sortedGenres = new();
|
||||
private List<string> _sortedTags = new();
|
||||
@@ -147,10 +186,13 @@
|
||||
_mediator.Send(new GetMovieById(MovieId)).IfSomeAsync(vm =>
|
||||
{
|
||||
_movie = vm;
|
||||
_sortedContentRatings = _movie.ContentRatings.OrderBy(cr => cr).ToList();
|
||||
_sortedLanguages = _movie.Languages.OrderBy(ci => ci.EnglishName).ToList();
|
||||
_sortedStudios = _movie.Studios.OrderBy(s => s).ToList();
|
||||
_sortedGenres = _movie.Genres.OrderBy(g => g).ToList();
|
||||
_sortedTags = _movie.Tags.OrderBy(t => t).ToList();
|
||||
_sortedDirectors = _movie.Directors.OrderBy(d => d).ToList();
|
||||
_sortedWriters = _movie.Writers.OrderBy(w => w).ToList();
|
||||
});
|
||||
|
||||
private async Task AddToCollection()
|
||||
|
||||
@@ -77,6 +77,18 @@
|
||||
</div>
|
||||
<MudCard Class="mb-6">
|
||||
<MudCardContent>
|
||||
@if (_sortedContentRatings.Any())
|
||||
{
|
||||
<div style="display: flex; flex-direction: row; flex-wrap: wrap">
|
||||
<MudText GutterBottom="true">Content Ratings: </MudText>
|
||||
<MudLink Href="@($"/search?query=content_rating%3a%22{Uri.EscapeDataString(_sortedContentRatings.Head())}%22")">@_sortedContentRatings.Head()</MudLink>
|
||||
@foreach (string contentRating in _sortedContentRatings.Skip(1))
|
||||
{
|
||||
<MudText>, </MudText>
|
||||
<MudLink Href="@($"/search?query=content_rating%3a%22{Uri.EscapeDataString(contentRating)}%22")">@contentRating</MudLink>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@if (_sortedLanguages.Any())
|
||||
{
|
||||
<div style="display: flex; flex-direction: row; flex-wrap: wrap">
|
||||
@@ -161,6 +173,7 @@
|
||||
public int ShowId { get; set; }
|
||||
|
||||
private TelevisionShowViewModel _show;
|
||||
private List<string> _sortedContentRatings = new();
|
||||
private List<CultureInfo> _sortedLanguages = new();
|
||||
private List<string> _sortedStudios = new();
|
||||
private List<string> _sortedGenres = new();
|
||||
@@ -179,6 +192,7 @@
|
||||
.IfSomeAsync(vm =>
|
||||
{
|
||||
_show = vm;
|
||||
_sortedContentRatings = _show.ContentRatings.OrderBy(cr => cr).ToList();
|
||||
_sortedLanguages = _show.Languages.OrderBy(ci => ci.EnglishName).ToList();
|
||||
_sortedStudios = _show.Studios.OrderBy(s => s).ToList();
|
||||
_sortedGenres = _show.Genres.OrderBy(g => g).ToList();
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Interfaces.Runtime;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using LanguageExt;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ErsatzTV.Services.RunOnce
|
||||
{
|
||||
public class PlatformSettingsService : IHostedService
|
||||
{
|
||||
private readonly ILogger<PlatformSettingsService> _logger;
|
||||
private readonly IServiceScopeFactory _serviceScopeFactory;
|
||||
|
||||
public PlatformSettingsService(
|
||||
IServiceScopeFactory serviceScopeFactory,
|
||||
ILogger<PlatformSettingsService> logger)
|
||||
{
|
||||
_serviceScopeFactory = serviceScopeFactory;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
using IServiceScope scope = _serviceScopeFactory.CreateScope();
|
||||
await using TvContext dbContext = scope.ServiceProvider.GetRequiredService<TvContext>();
|
||||
|
||||
IRuntimeInfo runtimeInfo = scope.ServiceProvider.GetRequiredService<IRuntimeInfo>();
|
||||
if (runtimeInfo != null && runtimeInfo.IsOSPlatform(OSPlatform.Windows))
|
||||
{
|
||||
_logger.LogInformation("Disabling ffmpeg reports on Windows platform");
|
||||
IConfigElementRepository repo = scope.ServiceProvider.GetRequiredService<IConfigElementRepository>();
|
||||
ConfigElementKey key = ConfigElementKey.FFmpegSaveReports;
|
||||
Option<ConfigElement> maybeExisting = await repo.Get(key);
|
||||
await maybeExisting.Match(
|
||||
ce =>
|
||||
{
|
||||
ce.Value = false.ToString();
|
||||
return repo.Update(ce);
|
||||
},
|
||||
() =>
|
||||
{
|
||||
var ce = new ConfigElement { Key = key.Key, Value = false.ToString() };
|
||||
return repo.Add(ce);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
using System.IO;
|
||||
using System.Reflection;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
|
||||
namespace ErsatzTV.Services.RunOnce
|
||||
{
|
||||
public class ResourceExtractorService : IHostedService
|
||||
{
|
||||
public async Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (!Directory.Exists(FileSystemLayout.ResourcesCacheFolder))
|
||||
{
|
||||
Directory.CreateDirectory(FileSystemLayout.ResourcesCacheFolder);
|
||||
}
|
||||
|
||||
Assembly assembly = typeof(ResourceExtractorService).GetTypeInfo().Assembly;
|
||||
|
||||
await ExtractResource(assembly, "background.png", cancellationToken);
|
||||
await ExtractResource(assembly, "ErsatzTV.png", cancellationToken);
|
||||
await ExtractResource(assembly, "Roboto-Regular.ttf", cancellationToken);
|
||||
}
|
||||
|
||||
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
|
||||
private async Task ExtractResource(Assembly assembly, string name, CancellationToken cancellationToken)
|
||||
{
|
||||
await using Stream resource = assembly.GetManifestResourceStream($"ErsatzTV.Resources.{name}");
|
||||
if (resource != null)
|
||||
{
|
||||
await using FileStream fs = File.Create(
|
||||
Path.Combine(FileSystemLayout.ResourcesCacheFolder, name));
|
||||
await resource.CopyToAsync(fs, cancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ using System.Threading;
|
||||
using System.Threading.Channels;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Application;
|
||||
using ErsatzTV.Application.Maintenance.Commands;
|
||||
using ErsatzTV.Application.MediaSources.Commands;
|
||||
using ErsatzTV.Application.Playouts.Commands;
|
||||
using ErsatzTV.Application.Plex.Commands;
|
||||
@@ -58,6 +59,7 @@ namespace ErsatzTV.Services
|
||||
{
|
||||
try
|
||||
{
|
||||
await DeleteOrphanedArtwork(cancellationToken);
|
||||
await RebuildSearchIndex(cancellationToken);
|
||||
await BuildPlayouts(cancellationToken);
|
||||
await ScanLocalMediaSources(cancellationToken);
|
||||
@@ -124,5 +126,8 @@ namespace ErsatzTV.Services
|
||||
|
||||
private ValueTask RebuildSearchIndex(CancellationToken cancellationToken) =>
|
||||
_workerChannel.WriteAsync(new RebuildSearchIndex(), cancellationToken);
|
||||
|
||||
private ValueTask DeleteOrphanedArtwork(CancellationToken cancellationToken) =>
|
||||
_workerChannel.WriteAsync(new DeleteOrphanedArtwork(), cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ using System.Threading;
|
||||
using System.Threading.Channels;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Application;
|
||||
using ErsatzTV.Application.Maintenance.Commands;
|
||||
using ErsatzTV.Application.MediaSources.Commands;
|
||||
using ErsatzTV.Application.Playouts.Commands;
|
||||
using ErsatzTV.Application.Search.Commands;
|
||||
@@ -72,6 +73,10 @@ namespace ErsatzTV.Services
|
||||
case RebuildSearchIndex rebuildSearchIndex:
|
||||
await mediator.Send(rebuildSearchIndex, cancellationToken);
|
||||
break;
|
||||
case DeleteOrphanedArtwork deleteOrphanedArtwork:
|
||||
_logger.LogInformation("Deleting orphaned artwork from the database");
|
||||
await mediator.Send(deleteOrphanedArtwork, cancellationToken);
|
||||
break;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
Text="@FormatText()"
|
||||
HighlightedText="@EntityName"/>
|
||||
</MudContainer>
|
||||
<MudSelect Label="Schedule" @bind-Value="_selectedSchedule" For="@(() => _selectedSchedule)" Class="mb-6 mx-4">
|
||||
<MudSelect T="ProgramScheduleViewModel" Label="Schedule" @bind-Value="_selectedSchedule" Class="mb-6 mx-4">
|
||||
@foreach (ProgramScheduleViewModel schedule in _schedules)
|
||||
{
|
||||
<MudSelectItem Value="@schedule">@schedule.Name</MudSelectItem>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user