Compare commits
20
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
076a88230e | ||
|
|
f06a04ed0e | ||
|
|
07d690a31f | ||
|
|
001453714a | ||
|
|
d303bc0158 | ||
|
|
51b671dec7 | ||
|
|
a5e1cc7c3d | ||
|
|
9ba6686c44 | ||
|
|
104d4a0cbd | ||
|
|
22c4fe2a27 | ||
|
|
7e0bdfdb40 | ||
|
|
6bdaca0222 | ||
|
|
67aa3a5a46 | ||
|
|
a0332e242c | ||
|
|
cd74859d28 | ||
|
|
470fba275b | ||
|
|
e42b000b7f | ||
|
|
489f8d92ff | ||
|
|
527d3c6e4b | ||
|
|
c33c037188 |
@@ -8,5 +8,6 @@ namespace ErsatzTV.Application.Channels
|
||||
string Name,
|
||||
int FFmpegProfileId,
|
||||
string Logo,
|
||||
string PreferredLanguageCode,
|
||||
StreamingMode StreamingMode);
|
||||
}
|
||||
|
||||
@@ -11,5 +11,6 @@ namespace ErsatzTV.Application.Channels.Commands
|
||||
string Number,
|
||||
int FFmpegProfileId,
|
||||
string Logo,
|
||||
string PreferredLanguageCode,
|
||||
StreamingMode StreamingMode) : IRequest<Either<BaseError, ChannelViewModel>>;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
@@ -9,6 +11,7 @@ using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using LanguageExt;
|
||||
using MediatR;
|
||||
using static ErsatzTV.Application.Channels.Mapper;
|
||||
using static LanguageExt.Prelude;
|
||||
|
||||
namespace ErsatzTV.Application.Channels.Commands
|
||||
{
|
||||
@@ -36,9 +39,10 @@ namespace ErsatzTV.Application.Channels.Commands
|
||||
_channelRepository.Add(c).Map(ProjectToViewModel);
|
||||
|
||||
private async Task<Validation<BaseError, Channel>> Validate(CreateChannel request) =>
|
||||
(ValidateName(request), await ValidateNumber(request), await FFmpegProfileMustExist(request))
|
||||
(ValidateName(request), await ValidateNumber(request), await FFmpegProfileMustExist(request),
|
||||
ValidatePreferredLanguage(request))
|
||||
.Apply(
|
||||
(name, number, ffmpegProfileId) =>
|
||||
(name, number, ffmpegProfileId, preferredLanguageCode) =>
|
||||
{
|
||||
var artwork = new List<Artwork>();
|
||||
if (!string.IsNullOrWhiteSpace(request.Logo))
|
||||
@@ -59,7 +63,8 @@ namespace ErsatzTV.Application.Channels.Commands
|
||||
Number = number,
|
||||
FFmpegProfileId = ffmpegProfileId,
|
||||
StreamingMode = request.StreamingMode,
|
||||
Artwork = artwork
|
||||
Artwork = artwork,
|
||||
PreferredLanguageCode = preferredLanguageCode
|
||||
};
|
||||
});
|
||||
|
||||
@@ -67,6 +72,14 @@ namespace ErsatzTV.Application.Channels.Commands
|
||||
createChannel.NotEmpty(c => c.Name)
|
||||
.Bind(_ => createChannel.NotLongerThan(50)(c => c.Name));
|
||||
|
||||
private Validation<BaseError, string> ValidatePreferredLanguage(CreateChannel createChannel) =>
|
||||
Optional(createChannel.PreferredLanguageCode)
|
||||
.Filter(
|
||||
lc => string.IsNullOrWhiteSpace(lc) || CultureInfo.GetCultures(CultureTypes.NeutralCultures).Any(
|
||||
ci => string.Equals(ci.ThreeLetterISOLanguageName, lc, StringComparison.OrdinalIgnoreCase)))
|
||||
.ToValidation<BaseError>("Preferred language code is invalid");
|
||||
|
||||
|
||||
private async Task<Validation<BaseError, string>> ValidateNumber(CreateChannel createChannel)
|
||||
{
|
||||
Option<Channel> maybeExistingChannel = await _channelRepository.GetByNumber(createChannel.Number);
|
||||
|
||||
@@ -12,5 +12,6 @@ namespace ErsatzTV.Application.Channels.Commands
|
||||
string Number,
|
||||
int FFmpegProfileId,
|
||||
string Logo,
|
||||
string PreferredLanguageCode,
|
||||
StreamingMode StreamingMode) : IRequest<Either<BaseError, ChannelViewModel>>;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading;
|
||||
@@ -32,6 +33,7 @@ namespace ErsatzTV.Application.Channels.Commands
|
||||
c.Name = update.Name;
|
||||
c.Number = update.Number;
|
||||
c.FFmpegProfileId = update.FFmpegProfileId;
|
||||
c.PreferredLanguageCode = update.PreferredLanguageCode;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(update.Logo))
|
||||
{
|
||||
@@ -65,8 +67,9 @@ namespace ErsatzTV.Application.Channels.Commands
|
||||
}
|
||||
|
||||
private async Task<Validation<BaseError, Channel>> Validate(UpdateChannel request) =>
|
||||
(await ChannelMustExist(request), ValidateName(request), await ValidateNumber(request))
|
||||
.Apply((channelToUpdate, _, _) => channelToUpdate);
|
||||
(await ChannelMustExist(request), ValidateName(request), await ValidateNumber(request),
|
||||
ValidatePreferredLanguage(request))
|
||||
.Apply((channelToUpdate, _, _, _) => channelToUpdate);
|
||||
|
||||
private Task<Validation<BaseError, Channel>> ChannelMustExist(UpdateChannel updateChannel) =>
|
||||
_channelRepository.Get(updateChannel.ChannelId)
|
||||
@@ -92,5 +95,12 @@ namespace ErsatzTV.Application.Channels.Commands
|
||||
|
||||
return BaseError.New("Channel number must be unique");
|
||||
}
|
||||
|
||||
private Validation<BaseError, string> ValidatePreferredLanguage(UpdateChannel updateChannel) =>
|
||||
Optional(updateChannel.PreferredLanguageCode)
|
||||
.Filter(
|
||||
lc => string.IsNullOrWhiteSpace(lc) || CultureInfo.GetCultures(CultureTypes.NeutralCultures).Any(
|
||||
ci => string.Equals(ci.ThreeLetterISOLanguageName, lc, StringComparison.OrdinalIgnoreCase)))
|
||||
.ToValidation<BaseError>("Preferred language code is invalid");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ namespace ErsatzTV.Application.Channels
|
||||
channel.Name,
|
||||
channel.FFmpegProfileId,
|
||||
GetLogo(channel),
|
||||
channel.PreferredLanguageCode,
|
||||
channel.StreamingMode);
|
||||
|
||||
private static string GetLogo(Channel channel) =>
|
||||
|
||||
@@ -134,6 +134,23 @@ namespace ErsatzTV.Application.FFmpegProfiles.Commands
|
||||
Directory.CreateDirectory(FileSystemLayout.FFmpegReportsFolder);
|
||||
}
|
||||
|
||||
await _configElementRepository.Get(ConfigElementKey.FFmpegPreferredLanguageCode).Match(
|
||||
ce =>
|
||||
{
|
||||
ce.Value = request.Settings.PreferredLanguageCode;
|
||||
_configElementRepository.Update(ce);
|
||||
},
|
||||
() =>
|
||||
{
|
||||
var ce = new ConfigElement
|
||||
{
|
||||
Key = ConfigElementKey.FFmpegPreferredLanguageCode.Key,
|
||||
Value = request.Settings.PreferredLanguageCode
|
||||
};
|
||||
_configElementRepository.Add(ce);
|
||||
});
|
||||
|
||||
|
||||
return Unit.Default;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
public string FFmpegPath { get; set; }
|
||||
public string FFprobePath { get; set; }
|
||||
public int DefaultFFmpegProfileId { get; set; }
|
||||
public string PreferredLanguageCode { get; set; }
|
||||
public bool SaveReports { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,13 +24,16 @@ namespace ErsatzTV.Application.FFmpegProfiles.Queries
|
||||
await _configElementRepository.GetValue<int>(ConfigElementKey.FFmpegDefaultProfileId);
|
||||
Option<bool> saveReports =
|
||||
await _configElementRepository.GetValue<bool>(ConfigElementKey.FFmpegSaveReports);
|
||||
Option<string> preferredLanguageCode =
|
||||
await _configElementRepository.GetValue<string>(ConfigElementKey.FFmpegPreferredLanguageCode);
|
||||
|
||||
return new FFmpegSettingsViewModel
|
||||
{
|
||||
FFmpegPath = ffmpegPath.IfNone(string.Empty),
|
||||
FFprobePath = ffprobePath.IfNone(string.Empty),
|
||||
DefaultFFmpegProfileId = defaultFFmpegProfileId.IfNone(0),
|
||||
SaveReports = saveReports.IfNone(false)
|
||||
SaveReports = saveReports.IfNone(false),
|
||||
PreferredLanguageCode = preferredLanguageCode.IfNone("eng")
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
using System.Collections.Generic;
|
||||
using MediatR;
|
||||
|
||||
namespace ErsatzTV.Application.MediaItems.Queries
|
||||
{
|
||||
public record SearchAllMediaItems(string SearchString) : IRequest<List<MediaItemSearchResultViewModel>>;
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using LanguageExt;
|
||||
using MediatR;
|
||||
using static ErsatzTV.Application.MediaItems.Mapper;
|
||||
|
||||
namespace ErsatzTV.Application.MediaItems.Queries
|
||||
{
|
||||
public class SearchAllMediaItemsHandler : IRequestHandler<SearchAllMediaItems, List<MediaItemSearchResultViewModel>>
|
||||
{
|
||||
private readonly IMediaItemRepository _mediaItemRepository;
|
||||
|
||||
public SearchAllMediaItemsHandler(IMediaItemRepository mediaItemRepository) =>
|
||||
_mediaItemRepository = mediaItemRepository;
|
||||
|
||||
public Task<List<MediaItemSearchResultViewModel>>
|
||||
Handle(SearchAllMediaItems request, CancellationToken cancellationToken) =>
|
||||
_mediaItemRepository.Search(request.SearchString).Map(list => list.Map(ProjectToSearchViewModel).ToList());
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
@@ -61,21 +62,30 @@ namespace ErsatzTV.Application.MediaSources.Commands
|
||||
var lastScan = new DateTimeOffset(localLibrary.LastScan ?? DateTime.MinValue, TimeSpan.Zero);
|
||||
if (forceScan || lastScan < DateTimeOffset.Now - TimeSpan.FromHours(6))
|
||||
{
|
||||
var sw = new Stopwatch();
|
||||
sw.Start();
|
||||
|
||||
foreach (LibraryPath libraryPath in localLibrary.Paths)
|
||||
{
|
||||
switch (localLibrary.MediaKind)
|
||||
{
|
||||
case LibraryMediaKind.Movies:
|
||||
await _movieFolderScanner.ScanFolder(libraryPath, ffprobePath);
|
||||
await _movieFolderScanner.ScanFolder(libraryPath, ffprobePath, lastScan);
|
||||
break;
|
||||
case LibraryMediaKind.Shows:
|
||||
await _televisionFolderScanner.ScanFolder(libraryPath, ffprobePath);
|
||||
await _televisionFolderScanner.ScanFolder(libraryPath, ffprobePath, lastScan);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
localLibrary.LastScan = DateTime.UtcNow;
|
||||
await _libraryRepository.UpdateLastScan(localLibrary);
|
||||
|
||||
sw.Stop();
|
||||
_logger.LogDebug(
|
||||
"Scan of library {Name} completed in {Duration}",
|
||||
localLibrary.Name,
|
||||
TimeSpan.FromMilliseconds(sw.ElapsedMilliseconds));
|
||||
}
|
||||
else
|
||||
{
|
||||
|
||||
@@ -64,6 +64,8 @@ namespace ErsatzTV.Application.Plex.Commands
|
||||
await maybeExisting.Match(
|
||||
existing =>
|
||||
{
|
||||
existing.Platform = server.Platform;
|
||||
existing.PlatformVersion = server.PlatformVersion;
|
||||
existing.ProductVersion = server.ProductVersion;
|
||||
existing.ServerName = server.ServerName;
|
||||
var toAdd = server.Connections
|
||||
|
||||
+11
-34
@@ -1,17 +1,14 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Core.FFmpeg;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Plex;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using LanguageExt;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using static LanguageExt.Prelude;
|
||||
|
||||
namespace ErsatzTV.Application.Streaming.Queries
|
||||
@@ -22,26 +19,23 @@ namespace ErsatzTV.Application.Streaming.Queries
|
||||
private readonly IConfigElementRepository _configElementRepository;
|
||||
private readonly FFmpegProcessService _ffmpegProcessService;
|
||||
private readonly ILocalFileSystem _localFileSystem;
|
||||
private readonly ILogger<GetPlayoutItemProcessByChannelNumberHandler> _logger;
|
||||
private readonly IMediaSourceRepository _mediaSourceRepository;
|
||||
private readonly IPlayoutRepository _playoutRepository;
|
||||
private readonly IPlexPathReplacementService _plexPathReplacementService;
|
||||
|
||||
public GetPlayoutItemProcessByChannelNumberHandler(
|
||||
IChannelRepository channelRepository,
|
||||
IConfigElementRepository configElementRepository,
|
||||
IPlayoutRepository playoutRepository,
|
||||
IMediaSourceRepository mediaSourceRepository,
|
||||
FFmpegProcessService ffmpegProcessService,
|
||||
ILocalFileSystem localFileSystem,
|
||||
ILogger<GetPlayoutItemProcessByChannelNumberHandler> logger)
|
||||
IPlexPathReplacementService plexPathReplacementService)
|
||||
: base(channelRepository, configElementRepository)
|
||||
{
|
||||
_configElementRepository = configElementRepository;
|
||||
_playoutRepository = playoutRepository;
|
||||
_mediaSourceRepository = mediaSourceRepository;
|
||||
_ffmpegProcessService = ffmpegProcessService;
|
||||
_localFileSystem = localFileSystem;
|
||||
_logger = logger;
|
||||
_plexPathReplacementService = plexPathReplacementService;
|
||||
}
|
||||
|
||||
protected override async Task<Either<BaseError, Process>> GetProcess(
|
||||
@@ -69,7 +63,7 @@ namespace ErsatzTV.Application.Streaming.Queries
|
||||
.Map(result => result.IfNone(false));
|
||||
|
||||
return Right<BaseError, Process>(
|
||||
_ffmpegProcessService.ForPlayoutItem(
|
||||
await _ffmpegProcessService.ForPlayoutItem(
|
||||
ffmpegPath,
|
||||
saveReports,
|
||||
channel,
|
||||
@@ -166,33 +160,16 @@ namespace ErsatzTV.Application.Streaming.Queries
|
||||
string path = file.Path;
|
||||
return playoutItem.MediaItem switch
|
||||
{
|
||||
PlexMovie plexMovie => await GetReplacementPlexPath(plexMovie.LibraryPathId, path),
|
||||
PlexEpisode plexEpisode => await GetReplacementPlexPath(plexEpisode.LibraryPathId, path),
|
||||
PlexMovie plexMovie => await _plexPathReplacementService.GetReplacementPlexPath(
|
||||
plexMovie.LibraryPathId,
|
||||
path),
|
||||
PlexEpisode plexEpisode => await _plexPathReplacementService.GetReplacementPlexPath(
|
||||
plexEpisode.LibraryPathId,
|
||||
path),
|
||||
_ => path
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<string> GetReplacementPlexPath(int libraryPathId, string path)
|
||||
{
|
||||
List<PlexPathReplacement> replacements =
|
||||
await _mediaSourceRepository.GetPlexPathReplacementsByLibraryId(libraryPathId);
|
||||
// TODO: this might barf mixing platforms (i.e. plex on linux, etv on windows)
|
||||
Option<PlexPathReplacement> maybeReplacement = replacements
|
||||
.SingleOrDefault(r => path.StartsWith(r.PlexPath + Path.DirectorySeparatorChar));
|
||||
return maybeReplacement.Match(
|
||||
replacement =>
|
||||
{
|
||||
string finalPath = path.Replace(replacement.PlexPath, replacement.LocalPath);
|
||||
_logger.LogInformation(
|
||||
"Replacing plex path {PlexPath} with {LocalPath} resulting in {FinalPath}",
|
||||
replacement.PlexPath,
|
||||
replacement.LocalPath,
|
||||
finalPath);
|
||||
return finalPath;
|
||||
},
|
||||
() => path);
|
||||
}
|
||||
|
||||
private record PlayoutItemWithPath(PlayoutItem PlayoutItem, string Path);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,8 @@ namespace ErsatzTV.Application.Television
|
||||
show.ShowMetadata.HeadOrNone().Map(GetFanArt).IfNone(string.Empty),
|
||||
show.ShowMetadata.HeadOrNone().Map(m => m.Genres.Map(g => g.Name).ToList()).IfNone(new List<string>()),
|
||||
show.ShowMetadata.HeadOrNone().Map(m => m.Tags.Map(g => g.Name).ToList()).IfNone(new List<string>()),
|
||||
show.ShowMetadata.HeadOrNone().Map(m => m.Studios.Map(s => s.Name).ToList()).IfNone(new List<string>()));
|
||||
show.ShowMetadata.HeadOrNone().Map(m => m.Studios.Map(s => s.Name).ToList())
|
||||
.IfNone(new List<string>()));
|
||||
|
||||
internal static TelevisionSeasonViewModel ProjectToViewModel(Season season) =>
|
||||
new(
|
||||
|
||||
@@ -18,7 +18,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
{
|
||||
var builder = new FFmpegComplexFilterBuilder();
|
||||
|
||||
Option<FFmpegComplexFilter> result = builder.Build();
|
||||
Option<FFmpegComplexFilter> result = builder.Build(0, 1);
|
||||
|
||||
result.IsNone.Should().BeTrue();
|
||||
}
|
||||
@@ -30,15 +30,15 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
FFmpegComplexFilterBuilder builder = new FFmpegComplexFilterBuilder()
|
||||
.WithAlignedAudio(duration);
|
||||
|
||||
Option<FFmpegComplexFilter> result = builder.Build();
|
||||
Option<FFmpegComplexFilter> result = builder.Build(0, 1);
|
||||
|
||||
result.IsSome.Should().BeTrue();
|
||||
result.IfSome(
|
||||
filter =>
|
||||
{
|
||||
filter.ComplexFilter.Should().Be($"[0:a]apad=whole_dur={duration.TotalMilliseconds}ms[a]");
|
||||
filter.ComplexFilter.Should().Be($"[0:1]apad=whole_dur={duration.TotalMilliseconds}ms[a]");
|
||||
filter.AudioLabel.Should().Be("[a]");
|
||||
filter.VideoLabel.Should().Be("0:V");
|
||||
filter.VideoLabel.Should().Be("0:0");
|
||||
});
|
||||
}
|
||||
|
||||
@@ -50,36 +50,36 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
.WithAlignedAudio(duration)
|
||||
.WithDeinterlace(true);
|
||||
|
||||
Option<FFmpegComplexFilter> result = builder.Build();
|
||||
Option<FFmpegComplexFilter> result = builder.Build(0, 1);
|
||||
|
||||
result.IsSome.Should().BeTrue();
|
||||
result.IfSome(
|
||||
filter =>
|
||||
{
|
||||
filter.ComplexFilter.Should().Be(
|
||||
$"[0:a]apad=whole_dur={duration.TotalMilliseconds}ms[a];[0:V]yadif=1[v]");
|
||||
$"[0:1]apad=whole_dur={duration.TotalMilliseconds}ms[a];[0:0]yadif=1[v]");
|
||||
filter.AudioLabel.Should().Be("[a]");
|
||||
filter.VideoLabel.Should().Be("[v]");
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
[TestCase(true, false, false, "[0:V]yadif=1[v]", "[v]")]
|
||||
[TestCase(true, true, false, "[0:V]yadif=1,scale=1920:1000:flags=fast_bilinear,setsar=1[v]", "[v]")]
|
||||
[TestCase(true, false, true, "[0:V]yadif=1,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2[v]", "[v]")]
|
||||
[TestCase(true, false, false, "[0:0]yadif=1[v]", "[v]")]
|
||||
[TestCase(true, true, false, "[0:0]yadif=1,scale=1920:1000:flags=fast_bilinear,setsar=1[v]", "[v]")]
|
||||
[TestCase(true, false, true, "[0:0]yadif=1,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2[v]", "[v]")]
|
||||
[TestCase(
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
"[0:V]yadif=1,scale=1920:1000:flags=fast_bilinear,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2[v]",
|
||||
"[0:0]yadif=1,scale=1920:1000:flags=fast_bilinear,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2[v]",
|
||||
"[v]")]
|
||||
[TestCase(false, true, false, "[0:V]scale=1920:1000:flags=fast_bilinear,setsar=1[v]", "[v]")]
|
||||
[TestCase(false, false, true, "[0:V]setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2[v]", "[v]")]
|
||||
[TestCase(false, true, false, "[0:0]scale=1920:1000:flags=fast_bilinear,setsar=1[v]", "[v]")]
|
||||
[TestCase(false, false, true, "[0:0]setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2[v]", "[v]")]
|
||||
[TestCase(
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
"[0:V]scale=1920:1000:flags=fast_bilinear,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2[v]",
|
||||
"[0:0]scale=1920:1000:flags=fast_bilinear,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2[v]",
|
||||
"[v]")]
|
||||
public void Should_Return_Software_Video_Filter(
|
||||
bool deinterlace,
|
||||
@@ -101,55 +101,55 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
builder = builder.WithBlackBars(new Resolution { Width = 1920, Height = 1080 });
|
||||
}
|
||||
|
||||
Option<FFmpegComplexFilter> result = builder.Build();
|
||||
Option<FFmpegComplexFilter> result = builder.Build(0, 1);
|
||||
|
||||
result.IsSome.Should().BeTrue();
|
||||
result.IfSome(
|
||||
filter =>
|
||||
{
|
||||
filter.ComplexFilter.Should().Be(expectedVideoFilter);
|
||||
filter.AudioLabel.Should().Be("0:a");
|
||||
filter.AudioLabel.Should().Be("0:1");
|
||||
filter.VideoLabel.Should().Be(expectedVideoLabel);
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
[TestCase(true, false, false, "[0:V]deinterlace_qsv[v]", "[v]")]
|
||||
[TestCase(true, false, false, "[0:0]deinterlace_qsv[v]", "[v]")]
|
||||
[TestCase(
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
"[0:V]deinterlace_qsv,scale_qsv=w=1920:h=1000,hwdownload,format=nv12,setsar=1,hwupload=extra_hw_frames=64[v]",
|
||||
"[0:0]deinterlace_qsv,scale_qsv=w=1920:h=1000,hwdownload,format=nv12,setsar=1,hwupload=extra_hw_frames=64[v]",
|
||||
"[v]")]
|
||||
[TestCase(
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
"[0:V]deinterlace_qsv,hwdownload,format=nv12,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload=extra_hw_frames=64[v]",
|
||||
"[0:0]deinterlace_qsv,hwdownload,format=nv12,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload=extra_hw_frames=64[v]",
|
||||
"[v]")]
|
||||
[TestCase(
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
"[0:V]deinterlace_qsv,scale_qsv=w=1920:h=1000,hwdownload,format=nv12,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload=extra_hw_frames=64[v]",
|
||||
"[0:0]deinterlace_qsv,scale_qsv=w=1920:h=1000,hwdownload,format=nv12,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload=extra_hw_frames=64[v]",
|
||||
"[v]")]
|
||||
[TestCase(
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
"[0:V]scale_qsv=w=1920:h=1000,hwdownload,format=nv12,setsar=1,hwupload=extra_hw_frames=64[v]",
|
||||
"[0:0]scale_qsv=w=1920:h=1000,hwdownload,format=nv12,setsar=1,hwupload=extra_hw_frames=64[v]",
|
||||
"[v]")]
|
||||
[TestCase(
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
"[0:V]hwdownload,format=nv12,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload=extra_hw_frames=64[v]",
|
||||
"[0:0]hwdownload,format=nv12,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload=extra_hw_frames=64[v]",
|
||||
"[v]")]
|
||||
[TestCase(
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
"[0:V]scale_qsv=w=1920:h=1000,hwdownload,format=nv12,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload=extra_hw_frames=64[v]",
|
||||
"[0:0]scale_qsv=w=1920:h=1000,hwdownload,format=nv12,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload=extra_hw_frames=64[v]",
|
||||
"[v]")]
|
||||
public void Should_Return_QSV_Video_Filter(
|
||||
bool deinterlace,
|
||||
@@ -172,14 +172,14 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
builder = builder.WithBlackBars(new Resolution { Width = 1920, Height = 1080 });
|
||||
}
|
||||
|
||||
Option<FFmpegComplexFilter> result = builder.Build();
|
||||
Option<FFmpegComplexFilter> result = builder.Build(0, 1);
|
||||
|
||||
result.IsSome.Should().BeTrue();
|
||||
result.IfSome(
|
||||
filter =>
|
||||
{
|
||||
filter.ComplexFilter.Should().Be(expectedVideoFilter);
|
||||
filter.AudioLabel.Should().Be("0:a");
|
||||
filter.AudioLabel.Should().Be("0:1");
|
||||
filter.VideoLabel.Should().Be(expectedVideoLabel);
|
||||
});
|
||||
}
|
||||
@@ -209,37 +209,37 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
"[0:V]scale_npp=1920:1000,hwdownload,format=nv12,setsar=1,hwupload[v]",
|
||||
"[0:0]scale_npp=1920:1000,hwdownload,format=nv12,setsar=1,hwupload[v]",
|
||||
"[v]")]
|
||||
[TestCase(
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
"[0:V]hwdownload,format=nv12,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]",
|
||||
"[0:0]hwdownload,format=nv12,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]",
|
||||
"[v]")]
|
||||
[TestCase(
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
"[0:V]scale_npp=1920:1000,hwdownload,format=nv12,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]",
|
||||
"[0:0]scale_npp=1920:1000,hwdownload,format=nv12,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]",
|
||||
"[v]")]
|
||||
[TestCase(
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
"[0:V]scale_npp=1920:1000,hwdownload,format=nv12,setsar=1,hwupload[v]",
|
||||
"[0:0]scale_npp=1920:1000,hwdownload,format=nv12,setsar=1,hwupload[v]",
|
||||
"[v]")]
|
||||
[TestCase(
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
"[0:V]hwdownload,format=nv12,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]",
|
||||
"[0:0]hwdownload,format=nv12,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]",
|
||||
"[v]")]
|
||||
[TestCase(
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
"[0:V]scale_npp=1920:1000,hwdownload,format=nv12,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]",
|
||||
"[0:0]scale_npp=1920:1000,hwdownload,format=nv12,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]",
|
||||
"[v]")]
|
||||
public void Should_Return_NVENC_Video_Filter(
|
||||
bool deinterlace,
|
||||
@@ -262,104 +262,104 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
builder = builder.WithBlackBars(new Resolution { Width = 1920, Height = 1080 });
|
||||
}
|
||||
|
||||
Option<FFmpegComplexFilter> result = builder.Build();
|
||||
Option<FFmpegComplexFilter> result = builder.Build(0, 1);
|
||||
|
||||
result.IsSome.Should().BeTrue();
|
||||
result.IfSome(
|
||||
filter =>
|
||||
{
|
||||
filter.ComplexFilter.Should().Be(expectedVideoFilter);
|
||||
filter.AudioLabel.Should().Be("0:a");
|
||||
filter.AudioLabel.Should().Be("0:1");
|
||||
filter.VideoLabel.Should().Be(expectedVideoLabel);
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
[TestCase("h264", true, false, false, "[0:V]deinterlace_vaapi[v]", "[v]")]
|
||||
[TestCase("h264", true, false, false, "[0:0]deinterlace_vaapi[v]", "[v]")]
|
||||
[TestCase(
|
||||
"h264",
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
"[0:V]deinterlace_vaapi,scale_vaapi=w=1920:h=1000,hwdownload,format=nv12|vaapi,setsar=1,hwupload[v]",
|
||||
"[0:0]deinterlace_vaapi,scale_vaapi=w=1920:h=1000,hwdownload,format=nv12|vaapi,setsar=1,hwupload[v]",
|
||||
"[v]")]
|
||||
[TestCase(
|
||||
"h264",
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
"[0:V]deinterlace_vaapi,hwdownload,format=nv12|vaapi,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]",
|
||||
"[0:0]deinterlace_vaapi,hwdownload,format=nv12|vaapi,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]",
|
||||
"[v]")]
|
||||
[TestCase(
|
||||
"h264",
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
"[0:V]deinterlace_vaapi,scale_vaapi=w=1920:h=1000,hwdownload,format=nv12|vaapi,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]",
|
||||
"[0:0]deinterlace_vaapi,scale_vaapi=w=1920:h=1000,hwdownload,format=nv12|vaapi,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]",
|
||||
"[v]")]
|
||||
[TestCase(
|
||||
"h264",
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
"[0:V]scale_vaapi=w=1920:h=1000,hwdownload,format=nv12|vaapi,setsar=1,hwupload[v]",
|
||||
"[0:0]scale_vaapi=w=1920:h=1000,hwdownload,format=nv12|vaapi,setsar=1,hwupload[v]",
|
||||
"[v]")]
|
||||
[TestCase(
|
||||
"h264",
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
"[0:V]hwdownload,format=nv12|vaapi,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]",
|
||||
"[0:0]hwdownload,format=nv12|vaapi,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]",
|
||||
"[v]")]
|
||||
[TestCase(
|
||||
"h264",
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
"[0:V]scale_vaapi=w=1920:h=1000,hwdownload,format=nv12|vaapi,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]",
|
||||
"[0:0]scale_vaapi=w=1920:h=1000,hwdownload,format=nv12|vaapi,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]",
|
||||
"[v]")]
|
||||
[TestCase("mpeg4", true, false, false, "[0:V]hwupload,deinterlace_vaapi[v]", "[v]")]
|
||||
[TestCase("mpeg4", true, false, false, "[0:0]hwupload,deinterlace_vaapi[v]", "[v]")]
|
||||
[TestCase(
|
||||
"mpeg4",
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
"[0:V]hwupload,deinterlace_vaapi,scale_vaapi=w=1920:h=1000,hwdownload,format=nv12|vaapi,setsar=1,hwupload[v]",
|
||||
"[0:0]hwupload,deinterlace_vaapi,scale_vaapi=w=1920:h=1000,hwdownload,format=nv12|vaapi,setsar=1,hwupload[v]",
|
||||
"[v]")]
|
||||
[TestCase(
|
||||
"mpeg4",
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
"[0:V]hwupload,deinterlace_vaapi,hwdownload,format=nv12|vaapi,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]",
|
||||
"[0:0]hwupload,deinterlace_vaapi,hwdownload,format=nv12|vaapi,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]",
|
||||
"[v]")]
|
||||
[TestCase(
|
||||
"mpeg4",
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
"[0:V]hwupload,deinterlace_vaapi,scale_vaapi=w=1920:h=1000,hwdownload,format=nv12|vaapi,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]",
|
||||
"[0:0]hwupload,deinterlace_vaapi,scale_vaapi=w=1920:h=1000,hwdownload,format=nv12|vaapi,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]",
|
||||
"[v]")]
|
||||
[TestCase(
|
||||
"mpeg4",
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
"[0:V]hwupload,scale_vaapi=w=1920:h=1000,hwdownload,format=nv12|vaapi,setsar=1,hwupload[v]",
|
||||
"[0:0]hwupload,scale_vaapi=w=1920:h=1000,hwdownload,format=nv12|vaapi,setsar=1,hwupload[v]",
|
||||
"[v]")]
|
||||
[TestCase(
|
||||
"mpeg4",
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
"[0:V]setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]",
|
||||
"[0:0]setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]",
|
||||
"[v]")]
|
||||
[TestCase(
|
||||
"mpeg4",
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
"[0:V]hwupload,scale_vaapi=w=1920:h=1000,hwdownload,format=nv12|vaapi,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]",
|
||||
"[0:0]hwupload,scale_vaapi=w=1920:h=1000,hwdownload,format=nv12|vaapi,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]",
|
||||
"[v]")]
|
||||
public void Should_Return_VAAPI_Video_Filter(
|
||||
string codec,
|
||||
@@ -384,14 +384,14 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
builder = builder.WithBlackBars(new Resolution { Width = 1920, Height = 1080 });
|
||||
}
|
||||
|
||||
Option<FFmpegComplexFilter> result = builder.Build();
|
||||
Option<FFmpegComplexFilter> result = builder.Build(0, 1);
|
||||
|
||||
result.IsSome.Should().BeTrue();
|
||||
result.IfSome(
|
||||
filter =>
|
||||
{
|
||||
filter.ComplexFilter.Should().Be(expectedVideoFilter);
|
||||
filter.AudioLabel.Should().Be("0:a");
|
||||
filter.AudioLabel.Should().Be("0:1");
|
||||
filter.VideoLabel.Should().Be(expectedVideoLabel);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -25,6 +25,8 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
StreamingMode.TransportStream,
|
||||
ffmpegProfile,
|
||||
new MediaVersion(),
|
||||
new MediaStream(),
|
||||
new MediaStream(),
|
||||
DateTimeOffset.Now,
|
||||
DateTimeOffset.Now);
|
||||
|
||||
@@ -40,6 +42,8 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
StreamingMode.HttpLiveStreaming,
|
||||
ffmpegProfile,
|
||||
new MediaVersion(),
|
||||
new MediaStream(),
|
||||
new MediaStream(),
|
||||
DateTimeOffset.Now,
|
||||
DateTimeOffset.Now);
|
||||
|
||||
@@ -55,6 +59,8 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
StreamingMode.TransportStream,
|
||||
ffmpegProfile,
|
||||
new MediaVersion(),
|
||||
new MediaStream(),
|
||||
new MediaStream(),
|
||||
DateTimeOffset.Now,
|
||||
DateTimeOffset.Now);
|
||||
|
||||
@@ -72,6 +78,8 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
StreamingMode.HttpLiveStreaming,
|
||||
ffmpegProfile,
|
||||
new MediaVersion(),
|
||||
new MediaStream(),
|
||||
new MediaStream(),
|
||||
DateTimeOffset.Now,
|
||||
DateTimeOffset.Now);
|
||||
|
||||
@@ -89,6 +97,8 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
StreamingMode.TransportStream,
|
||||
ffmpegProfile,
|
||||
new MediaVersion(),
|
||||
new MediaStream(),
|
||||
new MediaStream(),
|
||||
DateTimeOffset.Now,
|
||||
DateTimeOffset.Now);
|
||||
|
||||
@@ -104,6 +114,8 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
StreamingMode.HttpLiveStreaming,
|
||||
ffmpegProfile,
|
||||
new MediaVersion(),
|
||||
new MediaStream(),
|
||||
new MediaStream(),
|
||||
DateTimeOffset.Now,
|
||||
DateTimeOffset.Now);
|
||||
|
||||
@@ -121,6 +133,8 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
StreamingMode.TransportStream,
|
||||
ffmpegProfile,
|
||||
new MediaVersion(),
|
||||
new MediaStream(),
|
||||
new MediaStream(),
|
||||
now,
|
||||
now.AddMinutes(5));
|
||||
|
||||
@@ -139,6 +153,8 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
StreamingMode.HttpLiveStreaming,
|
||||
ffmpegProfile,
|
||||
new MediaVersion(),
|
||||
new MediaStream(),
|
||||
new MediaStream(),
|
||||
now,
|
||||
now.AddMinutes(5));
|
||||
|
||||
@@ -155,6 +171,8 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
StreamingMode.TransportStream,
|
||||
ffmpegProfile,
|
||||
new MediaVersion(),
|
||||
new MediaStream(),
|
||||
new MediaStream(),
|
||||
DateTimeOffset.Now,
|
||||
DateTimeOffset.Now);
|
||||
|
||||
@@ -177,6 +195,8 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
StreamingMode.TransportStream,
|
||||
ffmpegProfile,
|
||||
version,
|
||||
new MediaStream(),
|
||||
new MediaStream(),
|
||||
DateTimeOffset.Now,
|
||||
DateTimeOffset.Now);
|
||||
|
||||
@@ -199,6 +219,8 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
StreamingMode.TransportStream,
|
||||
ffmpegProfile,
|
||||
version,
|
||||
new MediaStream(),
|
||||
new MediaStream(),
|
||||
DateTimeOffset.Now,
|
||||
DateTimeOffset.Now);
|
||||
|
||||
@@ -221,6 +243,8 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
StreamingMode.TransportStream,
|
||||
ffmpegProfile,
|
||||
version,
|
||||
new MediaStream(),
|
||||
new MediaStream(),
|
||||
DateTimeOffset.Now,
|
||||
DateTimeOffset.Now);
|
||||
|
||||
@@ -244,6 +268,8 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
StreamingMode.TransportStream,
|
||||
ffmpegProfile,
|
||||
version,
|
||||
new MediaStream(),
|
||||
new MediaStream(),
|
||||
DateTimeOffset.Now,
|
||||
DateTimeOffset.Now);
|
||||
|
||||
@@ -267,6 +293,8 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
StreamingMode.HttpLiveStreaming,
|
||||
ffmpegProfile,
|
||||
version,
|
||||
new MediaStream(),
|
||||
new MediaStream(),
|
||||
DateTimeOffset.Now,
|
||||
DateTimeOffset.Now);
|
||||
|
||||
@@ -290,6 +318,8 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
StreamingMode.TransportStream,
|
||||
ffmpegProfile,
|
||||
version,
|
||||
new MediaStream(),
|
||||
new MediaStream(),
|
||||
DateTimeOffset.Now,
|
||||
DateTimeOffset.Now);
|
||||
|
||||
@@ -315,6 +345,8 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
StreamingMode.TransportStream,
|
||||
ffmpegProfile,
|
||||
version,
|
||||
new MediaStream(),
|
||||
new MediaStream(),
|
||||
DateTimeOffset.Now,
|
||||
DateTimeOffset.Now);
|
||||
|
||||
@@ -337,12 +369,14 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
|
||||
// not anamorphic
|
||||
var version = new MediaVersion
|
||||
{ Width = 1920, Height = 1080, SampleAspectRatio = "1:1", VideoCodec = "mpeg2video" };
|
||||
{ Width = 1920, Height = 1080, SampleAspectRatio = "1:1" };
|
||||
|
||||
FFmpegPlaybackSettings actual = _calculator.CalculateSettings(
|
||||
StreamingMode.TransportStream,
|
||||
ffmpegProfile,
|
||||
version,
|
||||
new MediaStream { Codec = "mpeg2video" },
|
||||
new MediaStream(),
|
||||
DateTimeOffset.Now,
|
||||
DateTimeOffset.Now);
|
||||
|
||||
@@ -365,12 +399,14 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
|
||||
// not anamorphic
|
||||
var version = new MediaVersion
|
||||
{ Width = 1920, Height = 1080, SampleAspectRatio = "1:1", VideoCodec = "mpeg2video" };
|
||||
{ Width = 1920, Height = 1080, SampleAspectRatio = "1:1" };
|
||||
|
||||
FFmpegPlaybackSettings actual = _calculator.CalculateSettings(
|
||||
StreamingMode.HttpLiveStreaming,
|
||||
ffmpegProfile,
|
||||
version,
|
||||
new MediaStream { Codec = "mpeg2video" },
|
||||
new MediaStream(),
|
||||
DateTimeOffset.Now,
|
||||
DateTimeOffset.Now);
|
||||
|
||||
@@ -392,12 +428,14 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
|
||||
// not anamorphic
|
||||
var version = new MediaVersion
|
||||
{ Width = 1920, Height = 1080, SampleAspectRatio = "1:1", VideoCodec = "libx264" };
|
||||
{ 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);
|
||||
|
||||
@@ -420,12 +458,14 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
|
||||
// not anamorphic
|
||||
var version = new MediaVersion
|
||||
{ Width = 1920, Height = 1080, SampleAspectRatio = "1:1", VideoCodec = "mpeg2video" };
|
||||
{ Width = 1920, Height = 1080, SampleAspectRatio = "1:1" };
|
||||
|
||||
FFmpegPlaybackSettings actual = _calculator.CalculateSettings(
|
||||
StreamingMode.TransportStream,
|
||||
ffmpegProfile,
|
||||
version,
|
||||
new MediaStream { Codec = "mpeg2video" },
|
||||
new MediaStream(),
|
||||
DateTimeOffset.Now,
|
||||
DateTimeOffset.Now);
|
||||
|
||||
@@ -452,6 +492,8 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
StreamingMode.TransportStream,
|
||||
ffmpegProfile,
|
||||
version,
|
||||
new MediaStream(),
|
||||
new MediaStream(),
|
||||
DateTimeOffset.Now,
|
||||
DateTimeOffset.Now);
|
||||
|
||||
@@ -473,12 +515,14 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
|
||||
// not anamorphic
|
||||
var version = new MediaVersion
|
||||
{ Width = 1920, Height = 1080, SampleAspectRatio = "1:1", VideoCodec = "mpeg2video" };
|
||||
{ Width = 1920, Height = 1080, SampleAspectRatio = "1:1" };
|
||||
|
||||
FFmpegPlaybackSettings actual = _calculator.CalculateSettings(
|
||||
StreamingMode.TransportStream,
|
||||
ffmpegProfile,
|
||||
version,
|
||||
new MediaStream { Codec = "mpeg2video" },
|
||||
new MediaStream(),
|
||||
DateTimeOffset.Now,
|
||||
DateTimeOffset.Now);
|
||||
|
||||
@@ -505,6 +549,8 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
StreamingMode.TransportStream,
|
||||
ffmpegProfile,
|
||||
version,
|
||||
new MediaStream(),
|
||||
new MediaStream(),
|
||||
DateTimeOffset.Now,
|
||||
DateTimeOffset.Now);
|
||||
|
||||
@@ -527,12 +573,14 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
|
||||
// not anamorphic
|
||||
var version = new MediaVersion
|
||||
{ Width = 1920, Height = 1080, SampleAspectRatio = "1:1", VideoCodec = "mpeg2video" };
|
||||
{ Width = 1920, Height = 1080, SampleAspectRatio = "1:1" };
|
||||
|
||||
FFmpegPlaybackSettings actual = _calculator.CalculateSettings(
|
||||
StreamingMode.TransportStream,
|
||||
ffmpegProfile,
|
||||
version,
|
||||
new MediaStream { Codec = "mpeg2video" },
|
||||
new MediaStream(),
|
||||
DateTimeOffset.Now,
|
||||
DateTimeOffset.Now);
|
||||
|
||||
@@ -550,12 +598,14 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
AudioCodec = "aac"
|
||||
};
|
||||
|
||||
var version = new MediaVersion { AudioCodec = "aac" };
|
||||
var version = new MediaVersion();
|
||||
|
||||
FFmpegPlaybackSettings actual = _calculator.CalculateSettings(
|
||||
StreamingMode.TransportStream,
|
||||
ffmpegProfile,
|
||||
version,
|
||||
new MediaStream(),
|
||||
new MediaStream { Codec = "aac" },
|
||||
DateTimeOffset.Now,
|
||||
DateTimeOffset.Now);
|
||||
|
||||
@@ -571,12 +621,14 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
AudioCodec = "aac"
|
||||
};
|
||||
|
||||
var version = new MediaVersion { AudioCodec = "ac3" };
|
||||
var version = new MediaVersion();
|
||||
|
||||
FFmpegPlaybackSettings actual = _calculator.CalculateSettings(
|
||||
StreamingMode.TransportStream,
|
||||
ffmpegProfile,
|
||||
version,
|
||||
new MediaStream(),
|
||||
new MediaStream { Codec = "ac3" },
|
||||
DateTimeOffset.Now,
|
||||
DateTimeOffset.Now);
|
||||
|
||||
@@ -592,12 +644,14 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
AudioCodec = "aac"
|
||||
};
|
||||
|
||||
var version = new MediaVersion { AudioCodec = "ac3" };
|
||||
var version = new MediaVersion();
|
||||
|
||||
FFmpegPlaybackSettings actual = _calculator.CalculateSettings(
|
||||
StreamingMode.TransportStream,
|
||||
ffmpegProfile,
|
||||
version,
|
||||
new MediaStream(),
|
||||
new MediaStream { Codec = "ac3" },
|
||||
DateTimeOffset.Now,
|
||||
DateTimeOffset.Now);
|
||||
|
||||
@@ -613,12 +667,14 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
AudioCodec = "aac"
|
||||
};
|
||||
|
||||
var version = new MediaVersion { AudioCodec = "ac3" };
|
||||
var version = new MediaVersion();
|
||||
|
||||
FFmpegPlaybackSettings actual = _calculator.CalculateSettings(
|
||||
StreamingMode.HttpLiveStreaming,
|
||||
ffmpegProfile,
|
||||
version,
|
||||
new MediaStream(),
|
||||
new MediaStream { Codec = "ac3" },
|
||||
DateTimeOffset.Now,
|
||||
DateTimeOffset.Now);
|
||||
|
||||
@@ -634,12 +690,14 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
AudioBitrate = 2424
|
||||
};
|
||||
|
||||
var version = new MediaVersion { AudioCodec = "ac3" };
|
||||
var version = new MediaVersion();
|
||||
|
||||
FFmpegPlaybackSettings actual = _calculator.CalculateSettings(
|
||||
StreamingMode.TransportStream,
|
||||
ffmpegProfile,
|
||||
version,
|
||||
new MediaStream(),
|
||||
new MediaStream { Codec = "ac3" },
|
||||
DateTimeOffset.Now,
|
||||
DateTimeOffset.Now);
|
||||
|
||||
@@ -655,12 +713,14 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
AudioBufferSize = 2424
|
||||
};
|
||||
|
||||
var version = new MediaVersion { AudioCodec = "ac3" };
|
||||
var version = new MediaVersion();
|
||||
|
||||
FFmpegPlaybackSettings actual = _calculator.CalculateSettings(
|
||||
StreamingMode.TransportStream,
|
||||
ffmpegProfile,
|
||||
version,
|
||||
new MediaStream(),
|
||||
new MediaStream { Codec = "ac3" },
|
||||
DateTimeOffset.Now,
|
||||
DateTimeOffset.Now);
|
||||
|
||||
@@ -678,12 +738,14 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
AudioChannels = 6
|
||||
};
|
||||
|
||||
var version = new MediaVersion { AudioCodec = "ac3" };
|
||||
var version = new MediaVersion();
|
||||
|
||||
FFmpegPlaybackSettings actual = _calculator.CalculateSettings(
|
||||
StreamingMode.TransportStream,
|
||||
ffmpegProfile,
|
||||
version,
|
||||
new MediaStream(),
|
||||
new MediaStream { Codec = "ac3" },
|
||||
DateTimeOffset.Now,
|
||||
DateTimeOffset.Now);
|
||||
|
||||
@@ -701,12 +763,14 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
AudioSampleRate = 48
|
||||
};
|
||||
|
||||
var version = new MediaVersion { AudioCodec = "ac3" };
|
||||
var version = new MediaVersion();
|
||||
|
||||
FFmpegPlaybackSettings actual = _calculator.CalculateSettings(
|
||||
StreamingMode.TransportStream,
|
||||
ffmpegProfile,
|
||||
version,
|
||||
new MediaStream(),
|
||||
new MediaStream { Codec = "ac3" },
|
||||
DateTimeOffset.Now,
|
||||
DateTimeOffset.Now);
|
||||
|
||||
@@ -723,12 +787,14 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
AudioChannels = 6
|
||||
};
|
||||
|
||||
var version = new MediaVersion { AudioCodec = "ac3" };
|
||||
var version = new MediaVersion();
|
||||
|
||||
FFmpegPlaybackSettings actual = _calculator.CalculateSettings(
|
||||
StreamingMode.TransportStream,
|
||||
ffmpegProfile,
|
||||
version,
|
||||
new MediaStream(),
|
||||
new MediaStream { Codec = "ac3" },
|
||||
DateTimeOffset.Now,
|
||||
DateTimeOffset.Now);
|
||||
|
||||
@@ -745,12 +811,14 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
AudioSampleRate = 48
|
||||
};
|
||||
|
||||
var version = new MediaVersion { AudioCodec = "ac3" };
|
||||
var version = new MediaVersion();
|
||||
|
||||
FFmpegPlaybackSettings actual = _calculator.CalculateSettings(
|
||||
StreamingMode.TransportStream,
|
||||
ffmpegProfile,
|
||||
version,
|
||||
new MediaStream(),
|
||||
new MediaStream { Codec = "ac3" },
|
||||
DateTimeOffset.Now,
|
||||
DateTimeOffset.Now);
|
||||
|
||||
@@ -775,6 +843,8 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
StreamingMode.TransportStream,
|
||||
ffmpegProfile,
|
||||
new MediaVersion(),
|
||||
new MediaStream(),
|
||||
new MediaStream(),
|
||||
DateTimeOffset.Now,
|
||||
DateTimeOffset.Now);
|
||||
|
||||
|
||||
@@ -81,7 +81,10 @@ namespace ErsatzTV.Core.Tests.Metadata
|
||||
);
|
||||
var libraryPath = new LibraryPath { Path = BadFakeRoot };
|
||||
|
||||
Either<BaseError, Unit> result = await service.ScanFolder(libraryPath, FFprobePath);
|
||||
Either<BaseError, Unit> result = await service.ScanFolder(
|
||||
libraryPath,
|
||||
FFprobePath,
|
||||
DateTimeOffset.MinValue);
|
||||
|
||||
result.IsLeft.Should().BeTrue();
|
||||
result.IfLeft(error => error.Should().BeOfType<MediaSourceInaccessible>());
|
||||
@@ -101,7 +104,10 @@ namespace ErsatzTV.Core.Tests.Metadata
|
||||
);
|
||||
var libraryPath = new LibraryPath { Id = 1, Path = FakeRoot };
|
||||
|
||||
Either<BaseError, Unit> result = await service.ScanFolder(libraryPath, FFprobePath);
|
||||
Either<BaseError, Unit> result = await service.ScanFolder(
|
||||
libraryPath,
|
||||
FFprobePath,
|
||||
DateTimeOffset.MinValue);
|
||||
|
||||
result.IsRight.Should().BeTrue();
|
||||
|
||||
@@ -137,7 +143,10 @@ namespace ErsatzTV.Core.Tests.Metadata
|
||||
);
|
||||
var libraryPath = new LibraryPath { Id = 1, Path = FakeRoot };
|
||||
|
||||
Either<BaseError, Unit> result = await service.ScanFolder(libraryPath, FFprobePath);
|
||||
Either<BaseError, Unit> result = await service.ScanFolder(
|
||||
libraryPath,
|
||||
FFprobePath,
|
||||
DateTimeOffset.MinValue);
|
||||
|
||||
result.IsRight.Should().BeTrue();
|
||||
|
||||
@@ -174,7 +183,10 @@ namespace ErsatzTV.Core.Tests.Metadata
|
||||
);
|
||||
var libraryPath = new LibraryPath { Id = 1, Path = FakeRoot };
|
||||
|
||||
Either<BaseError, Unit> result = await service.ScanFolder(libraryPath, FFprobePath);
|
||||
Either<BaseError, Unit> result = await service.ScanFolder(
|
||||
libraryPath,
|
||||
FFprobePath,
|
||||
DateTimeOffset.MinValue);
|
||||
|
||||
result.IsRight.Should().BeTrue();
|
||||
|
||||
@@ -215,7 +227,57 @@ namespace ErsatzTV.Core.Tests.Metadata
|
||||
);
|
||||
var libraryPath = new LibraryPath { Id = 1, Path = FakeRoot };
|
||||
|
||||
Either<BaseError, Unit> result = await service.ScanFolder(libraryPath, FFprobePath);
|
||||
Either<BaseError, Unit> result = await service.ScanFolder(
|
||||
libraryPath,
|
||||
FFprobePath,
|
||||
DateTimeOffset.MinValue);
|
||||
|
||||
result.IsRight.Should().BeTrue();
|
||||
|
||||
_movieRepository.Verify(x => x.GetOrAdd(It.IsAny<LibraryPath>(), It.IsAny<string>()), Times.Once);
|
||||
_movieRepository.Verify(x => x.GetOrAdd(libraryPath, moviePath), Times.Once);
|
||||
|
||||
_localStatisticsProvider.Verify(
|
||||
x => x.RefreshStatistics(
|
||||
FFprobePath,
|
||||
It.Is<Movie>(i => i.MediaVersions.Head().MediaFiles.Head().Path == moviePath)),
|
||||
Times.Once);
|
||||
|
||||
_localMetadataProvider.Verify(
|
||||
x => x.RefreshFallbackMetadata(
|
||||
It.Is<Movie>(i => i.MediaVersions.Head().MediaFiles.Head().Path == moviePath)),
|
||||
Times.Once);
|
||||
|
||||
_imageCache.Verify(
|
||||
x => x.CopyArtworkToCache(posterPath, ArtworkKind.Poster),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task NewMovie_Statistics_And_FallbackMetadata_And_FolderPoster(
|
||||
[ValueSource(typeof(LocalFolderScanner), nameof(LocalFolderScanner.VideoFileExtensions))]
|
||||
string videoExtension,
|
||||
[ValueSource(typeof(LocalFolderScanner), nameof(LocalFolderScanner.ImageFileExtensions))]
|
||||
string imageExtension)
|
||||
{
|
||||
string moviePath = Path.Combine(
|
||||
FakeRoot,
|
||||
Path.Combine("Movie (2020)", $"Movie (2020){videoExtension}"));
|
||||
|
||||
string posterPath = Path.Combine(
|
||||
Path.GetDirectoryName(moviePath) ?? string.Empty,
|
||||
$"folder.{imageExtension}");
|
||||
|
||||
MovieFolderScanner service = GetService(
|
||||
new FakeFileEntry(moviePath) { LastWriteTime = DateTime.Now },
|
||||
new FakeFileEntry(posterPath) { LastWriteTime = DateTime.Now }
|
||||
);
|
||||
var libraryPath = new LibraryPath { Id = 1, Path = FakeRoot };
|
||||
|
||||
Either<BaseError, Unit> result = await service.ScanFolder(
|
||||
libraryPath,
|
||||
FFprobePath,
|
||||
DateTimeOffset.MinValue);
|
||||
|
||||
result.IsRight.Should().BeTrue();
|
||||
|
||||
@@ -259,7 +321,10 @@ namespace ErsatzTV.Core.Tests.Metadata
|
||||
);
|
||||
var libraryPath = new LibraryPath { Id = 1, Path = FakeRoot };
|
||||
|
||||
Either<BaseError, Unit> result = await service.ScanFolder(libraryPath, FFprobePath);
|
||||
Either<BaseError, Unit> result = await service.ScanFolder(
|
||||
libraryPath,
|
||||
FFprobePath,
|
||||
DateTimeOffset.MinValue);
|
||||
|
||||
result.IsRight.Should().BeTrue();
|
||||
|
||||
@@ -302,7 +367,10 @@ namespace ErsatzTV.Core.Tests.Metadata
|
||||
);
|
||||
var libraryPath = new LibraryPath { Id = 1, Path = FakeRoot };
|
||||
|
||||
Either<BaseError, Unit> result = await service.ScanFolder(libraryPath, FFprobePath);
|
||||
Either<BaseError, Unit> result = await service.ScanFolder(
|
||||
libraryPath,
|
||||
FFprobePath,
|
||||
DateTimeOffset.MinValue);
|
||||
|
||||
result.IsRight.Should().BeTrue();
|
||||
|
||||
@@ -341,7 +409,10 @@ namespace ErsatzTV.Core.Tests.Metadata
|
||||
);
|
||||
var libraryPath = new LibraryPath { Id = 1, Path = FakeRoot };
|
||||
|
||||
Either<BaseError, Unit> result = await service.ScanFolder(libraryPath, FFprobePath);
|
||||
Either<BaseError, Unit> result = await service.ScanFolder(
|
||||
libraryPath,
|
||||
FFprobePath,
|
||||
DateTimeOffset.MinValue);
|
||||
|
||||
result.IsRight.Should().BeTrue();
|
||||
|
||||
@@ -374,7 +445,10 @@ namespace ErsatzTV.Core.Tests.Metadata
|
||||
);
|
||||
var libraryPath = new LibraryPath { Id = 1, Path = FakeRoot };
|
||||
|
||||
Either<BaseError, Unit> result = await service.ScanFolder(libraryPath, FFprobePath);
|
||||
Either<BaseError, Unit> result = await service.ScanFolder(
|
||||
libraryPath,
|
||||
FFprobePath,
|
||||
DateTimeOffset.MinValue);
|
||||
|
||||
result.IsRight.Should().BeTrue();
|
||||
|
||||
@@ -409,7 +483,10 @@ namespace ErsatzTV.Core.Tests.Metadata
|
||||
);
|
||||
var libraryPath = new LibraryPath { Id = 1, Path = FakeRoot };
|
||||
|
||||
Either<BaseError, Unit> result = await service.ScanFolder(libraryPath, FFprobePath);
|
||||
Either<BaseError, Unit> result = await service.ScanFolder(
|
||||
libraryPath,
|
||||
FFprobePath,
|
||||
DateTimeOffset.MinValue);
|
||||
|
||||
result.IsRight.Should().BeTrue();
|
||||
|
||||
@@ -433,7 +510,10 @@ namespace ErsatzTV.Core.Tests.Metadata
|
||||
);
|
||||
var libraryPath = new LibraryPath { Id = 1, Path = FakeRoot };
|
||||
|
||||
Either<BaseError, Unit> result = await service.ScanFolder(libraryPath, FFprobePath);
|
||||
Either<BaseError, Unit> result = await service.ScanFolder(
|
||||
libraryPath,
|
||||
FFprobePath,
|
||||
DateTimeOffset.MinValue);
|
||||
|
||||
result.IsRight.Should().BeTrue();
|
||||
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Interfaces.Runtime;
|
||||
using ErsatzTV.Core.Plex;
|
||||
using FluentAssertions;
|
||||
using LanguageExt;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Moq;
|
||||
using NUnit.Framework;
|
||||
|
||||
namespace ErsatzTV.Core.Tests.Plex
|
||||
{
|
||||
[TestFixture]
|
||||
public class PlexPathReplacementServiceTests
|
||||
{
|
||||
[Test]
|
||||
public async Task PlexWindows_To_EtvWindows()
|
||||
{
|
||||
var replacements = new List<PlexPathReplacement>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Id = 1,
|
||||
PlexPath = @"C:\Something\Some Shared Folder",
|
||||
LocalPath = @"C:\Something Else\Some Shared Folder",
|
||||
PlexMediaSource = new PlexMediaSource { Platform = "Windows" }
|
||||
}
|
||||
};
|
||||
|
||||
var repo = new Mock<IMediaSourceRepository>();
|
||||
repo.Setup(x => x.GetPlexPathReplacementsByLibraryId(It.IsAny<int>())).Returns(replacements.AsTask());
|
||||
|
||||
var runtime = new Mock<IRuntimeInfo>();
|
||||
runtime.Setup(x => x.IsOSPlatform(OSPlatform.Windows)).Returns(true);
|
||||
|
||||
var service = new PlexPathReplacementService(
|
||||
repo.Object,
|
||||
runtime.Object,
|
||||
new Mock<ILogger<PlexPathReplacementService>>().Object);
|
||||
|
||||
string result = await service.GetReplacementPlexPath(
|
||||
0,
|
||||
@"C:\Something\Some Shared Folder\Some Movie\Some Movie.mkv");
|
||||
|
||||
result.Should().Be(@"C:\Something Else\Some Shared Folder\Some Movie\Some Movie.mkv");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task PlexWindows_To_EtvLinux()
|
||||
{
|
||||
var replacements = new List<PlexPathReplacement>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Id = 1,
|
||||
PlexPath = @"C:\Something\Some Shared Folder",
|
||||
LocalPath = @"/mnt/something else/Some Shared Folder",
|
||||
PlexMediaSource = new PlexMediaSource { Platform = "Windows" }
|
||||
}
|
||||
};
|
||||
|
||||
var repo = new Mock<IMediaSourceRepository>();
|
||||
repo.Setup(x => x.GetPlexPathReplacementsByLibraryId(It.IsAny<int>())).Returns(replacements.AsTask());
|
||||
|
||||
var runtime = new Mock<IRuntimeInfo>();
|
||||
runtime.Setup(x => x.IsOSPlatform(OSPlatform.Windows)).Returns(false);
|
||||
|
||||
var service = new PlexPathReplacementService(
|
||||
repo.Object,
|
||||
runtime.Object,
|
||||
new Mock<ILogger<PlexPathReplacementService>>().Object);
|
||||
|
||||
string result = await service.GetReplacementPlexPath(
|
||||
0,
|
||||
@"C:\Something\Some Shared Folder\Some Movie\Some Movie.mkv");
|
||||
|
||||
result.Should().Be(@"/mnt/something else/Some Shared Folder/Some Movie/Some Movie.mkv");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task PlexWindows_To_EtvLinux_UncPath()
|
||||
{
|
||||
var replacements = new List<PlexPathReplacement>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Id = 1,
|
||||
PlexPath = @"\\192.168.1.100\Something\Some Shared Folder",
|
||||
LocalPath = @"/mnt/something else/Some Shared Folder",
|
||||
PlexMediaSource = new PlexMediaSource { Platform = "Windows" }
|
||||
}
|
||||
};
|
||||
|
||||
var repo = new Mock<IMediaSourceRepository>();
|
||||
repo.Setup(x => x.GetPlexPathReplacementsByLibraryId(It.IsAny<int>())).Returns(replacements.AsTask());
|
||||
|
||||
var runtime = new Mock<IRuntimeInfo>();
|
||||
runtime.Setup(x => x.IsOSPlatform(OSPlatform.Windows)).Returns(false);
|
||||
|
||||
var service = new PlexPathReplacementService(
|
||||
repo.Object,
|
||||
runtime.Object,
|
||||
new Mock<ILogger<PlexPathReplacementService>>().Object);
|
||||
|
||||
string result = await service.GetReplacementPlexPath(
|
||||
0,
|
||||
@"\\192.168.1.100\Something\Some Shared Folder\Some Movie\Some Movie.mkv");
|
||||
|
||||
result.Should().Be(@"/mnt/something else/Some Shared Folder/Some Movie/Some Movie.mkv");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task PlexWindows_To_EtvLinux_UncPathWithTrailingSlash()
|
||||
{
|
||||
var replacements = new List<PlexPathReplacement>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Id = 1,
|
||||
PlexPath = @"\\192.168.1.100\Something\Some Shared Folder\",
|
||||
LocalPath = @"/mnt/something else/Some Shared Folder/",
|
||||
PlexMediaSource = new PlexMediaSource { Platform = "Windows" }
|
||||
}
|
||||
};
|
||||
|
||||
var repo = new Mock<IMediaSourceRepository>();
|
||||
repo.Setup(x => x.GetPlexPathReplacementsByLibraryId(It.IsAny<int>())).Returns(replacements.AsTask());
|
||||
|
||||
var runtime = new Mock<IRuntimeInfo>();
|
||||
runtime.Setup(x => x.IsOSPlatform(OSPlatform.Windows)).Returns(false);
|
||||
|
||||
var service = new PlexPathReplacementService(
|
||||
repo.Object,
|
||||
runtime.Object,
|
||||
new Mock<ILogger<PlexPathReplacementService>>().Object);
|
||||
|
||||
string result = await service.GetReplacementPlexPath(
|
||||
0,
|
||||
@"\\192.168.1.100\Something\Some Shared Folder\Some Movie\Some Movie.mkv");
|
||||
|
||||
result.Should().Be(@"/mnt/something else/Some Shared Folder/Some Movie/Some Movie.mkv");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task PlexLinux_To_EtvWindows()
|
||||
{
|
||||
var replacements = new List<PlexPathReplacement>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Id = 1,
|
||||
PlexPath = @"/mnt/something/Some Shared Folder",
|
||||
LocalPath = @"C:\Something Else\Some Shared Folder",
|
||||
PlexMediaSource = new PlexMediaSource { Platform = "Linux" }
|
||||
}
|
||||
};
|
||||
|
||||
var repo = new Mock<IMediaSourceRepository>();
|
||||
repo.Setup(x => x.GetPlexPathReplacementsByLibraryId(It.IsAny<int>())).Returns(replacements.AsTask());
|
||||
|
||||
var runtime = new Mock<IRuntimeInfo>();
|
||||
runtime.Setup(x => x.IsOSPlatform(OSPlatform.Windows)).Returns(true);
|
||||
|
||||
var service = new PlexPathReplacementService(
|
||||
repo.Object,
|
||||
runtime.Object,
|
||||
new Mock<ILogger<PlexPathReplacementService>>().Object);
|
||||
|
||||
string result = await service.GetReplacementPlexPath(
|
||||
0,
|
||||
@"/mnt/something/Some Shared Folder/Some Movie/Some Movie.mkv");
|
||||
|
||||
result.Should().Be(@"C:\Something Else\Some Shared Folder\Some Movie\Some Movie.mkv");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task PlexLinux_To_EtvLinux()
|
||||
{
|
||||
var replacements = new List<PlexPathReplacement>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Id = 1,
|
||||
PlexPath = @"/mnt/something/Some Shared Folder",
|
||||
LocalPath = @"/mnt/something else/Some Shared Folder",
|
||||
PlexMediaSource = new PlexMediaSource { Platform = "Linux" }
|
||||
}
|
||||
};
|
||||
|
||||
var repo = new Mock<IMediaSourceRepository>();
|
||||
repo.Setup(x => x.GetPlexPathReplacementsByLibraryId(It.IsAny<int>())).Returns(replacements.AsTask());
|
||||
|
||||
var runtime = new Mock<IRuntimeInfo>();
|
||||
runtime.Setup(x => x.IsOSPlatform(OSPlatform.Windows)).Returns(false);
|
||||
|
||||
var service = new PlexPathReplacementService(
|
||||
repo.Object,
|
||||
runtime.Object,
|
||||
new Mock<ILogger<PlexPathReplacementService>>().Object);
|
||||
|
||||
string result = await service.GetReplacementPlexPath(
|
||||
0,
|
||||
@"/mnt/something/Some Shared Folder/Some Movie/Some Movie.mkv");
|
||||
|
||||
result.Should().Be(@"/mnt/something else/Some Shared Folder/Some Movie/Some Movie.mkv");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -16,8 +16,7 @@ namespace ErsatzTV.Core.Domain
|
||||
public FFmpegProfile FFmpegProfile { get; set; }
|
||||
public StreamingMode StreamingMode { get; set; }
|
||||
public List<Playout> Playouts { get; set; }
|
||||
|
||||
public List<Artwork> Artwork { get; set; }
|
||||
// public SourceMode Mode { get; set; }
|
||||
public string PreferredLanguageCode { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
public static ConfigElementKey FFmpegDefaultProfileId => new("ffmpeg.default_profile_id");
|
||||
public static ConfigElementKey FFmpegDefaultResolutionId => new("ffmpeg.default_resolution_id");
|
||||
public static ConfigElementKey FFmpegSaveReports => new("ffmpeg.save_reports");
|
||||
public static ConfigElementKey FFmpegPreferredLanguageCode => new("ffmpeg.preferred_language_code");
|
||||
public static ConfigElementKey SearchIndexVersion => new("search_index.version");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
namespace ErsatzTV.Core.Domain
|
||||
{
|
||||
public class MediaStream
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public int Index { get; set; }
|
||||
public string Codec { get; set; }
|
||||
public string Profile { get; set; }
|
||||
public MediaStreamKind MediaStreamKind { get; set; }
|
||||
public string Language { get; set; }
|
||||
public int Channels { get; set; }
|
||||
public string Title { get; set; }
|
||||
public bool Default { get; set; }
|
||||
public bool Forced { get; set; }
|
||||
public int MediaVersionId { get; set; }
|
||||
public MediaVersion MediaVersion { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace ErsatzTV.Core.Domain
|
||||
{
|
||||
public enum MediaStreamKind
|
||||
{
|
||||
Video = 1,
|
||||
Audio = 2,
|
||||
Subtitle = 3
|
||||
}
|
||||
}
|
||||
@@ -8,15 +8,21 @@ namespace ErsatzTV.Core.Domain
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string Name { get; set; }
|
||||
|
||||
public List<MediaFile> MediaFiles { get; set; }
|
||||
|
||||
public List<MediaStream> Streams { get; set; }
|
||||
public TimeSpan Duration { get; set; }
|
||||
public string SampleAspectRatio { get; set; }
|
||||
public string DisplayAspectRatio { get; set; }
|
||||
|
||||
[Obsolete("Use MediaSource instead")]
|
||||
public string VideoCodec { get; set; }
|
||||
|
||||
[Obsolete("Use MediaSource instead")]
|
||||
public string VideoProfile { get; set; }
|
||||
|
||||
[Obsolete("Use MediaSource instead")]
|
||||
public string AudioCodec { get; set; }
|
||||
|
||||
public VideoScanKind VideoScanKind { get; set; }
|
||||
public DateTime DateAdded { get; set; }
|
||||
public DateTime DateUpdated { get; set; }
|
||||
|
||||
@@ -6,6 +6,8 @@ namespace ErsatzTV.Core.Domain
|
||||
{
|
||||
public string ServerName { get; set; }
|
||||
public string ProductVersion { get; set; }
|
||||
public string Platform { get; set; }
|
||||
public string PlatformVersion { get; set; }
|
||||
public string ClientIdentifier { get; set; }
|
||||
|
||||
// public bool IsOwned { get; set; }
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
namespace ErsatzTV.Core.Domain
|
||||
{
|
||||
public enum SourceMode
|
||||
{
|
||||
Transcode,
|
||||
DirectPlay,
|
||||
DirectPaths
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@
|
||||
{
|
||||
public override string ToString() =>
|
||||
$@"ffconcat version 1.0
|
||||
file {Scheme}://{Host}/ffmpeg/stream/{ChannelNumber}
|
||||
file {Scheme}://{Host}/ffmpeg/stream/{ChannelNumber}";
|
||||
file http://localhost:8409/ffmpeg/stream/{ChannelNumber}
|
||||
file http://localhost:8409/ffmpeg/stream/{ChannelNumber}";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,12 +54,12 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
return this;
|
||||
}
|
||||
|
||||
public Option<FFmpegComplexFilter> Build()
|
||||
public Option<FFmpegComplexFilter> Build(int videoStreamIndex, int audioStreamIndex)
|
||||
{
|
||||
var complexFilter = new StringBuilder();
|
||||
|
||||
var videoLabel = "0:V";
|
||||
var audioLabel = "0:a";
|
||||
var videoLabel = $"0:{videoStreamIndex}";
|
||||
var audioLabel = $"0:{audioStreamIndex}";
|
||||
|
||||
HardwareAccelerationKind acceleration = _hardwareAccelerationKind.IfNone(HardwareAccelerationKind.None);
|
||||
bool isHardwareDecode = acceleration switch
|
||||
|
||||
@@ -45,6 +45,8 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
StreamingMode streamingMode,
|
||||
FFmpegProfile ffmpegProfile,
|
||||
MediaVersion version,
|
||||
MediaStream videoStream,
|
||||
MediaStream audioStream,
|
||||
DateTimeOffset start,
|
||||
DateTimeOffset now)
|
||||
{
|
||||
@@ -85,7 +87,7 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
}
|
||||
|
||||
if (result.ScaledSize.IsSome || result.PadToDesiredResolution ||
|
||||
NeedToNormalizeVideoCodec(ffmpegProfile, version))
|
||||
NeedToNormalizeVideoCodec(ffmpegProfile, videoStream))
|
||||
{
|
||||
result.VideoCodec = ffmpegProfile.VideoCodec;
|
||||
result.VideoBitrate = ffmpegProfile.VideoBitrate;
|
||||
@@ -96,7 +98,7 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
result.VideoCodec = "copy";
|
||||
}
|
||||
|
||||
if (NeedToNormalizeAudioCodec(ffmpegProfile, version))
|
||||
if (NeedToNormalizeAudioCodec(ffmpegProfile, audioStream))
|
||||
{
|
||||
result.AudioCodec = ffmpegProfile.AudioCodec;
|
||||
result.AudioBitrate = ffmpegProfile.AudioBitrate;
|
||||
@@ -104,7 +106,11 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
|
||||
if (ffmpegProfile.NormalizeAudio)
|
||||
{
|
||||
result.AudioChannels = ffmpegProfile.AudioChannels;
|
||||
if (audioStream.Channels != ffmpegProfile.AudioChannels)
|
||||
{
|
||||
result.AudioChannels = ffmpegProfile.AudioChannels;
|
||||
}
|
||||
|
||||
result.AudioSampleRate = ffmpegProfile.AudioSampleRate;
|
||||
result.AudioDuration = version.Duration;
|
||||
}
|
||||
@@ -152,11 +158,11 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
private static bool IsOddSize(MediaVersion version) =>
|
||||
version.Height % 2 == 1 || version.Width % 2 == 1;
|
||||
|
||||
private static bool NeedToNormalizeVideoCodec(FFmpegProfile ffmpegProfile, MediaVersion version) =>
|
||||
ffmpegProfile.NormalizeVideoCodec && ffmpegProfile.VideoCodec != version.VideoCodec;
|
||||
private static bool NeedToNormalizeVideoCodec(FFmpegProfile ffmpegProfile, MediaStream videoStream) =>
|
||||
ffmpegProfile.NormalizeVideoCodec && ffmpegProfile.VideoCodec != videoStream.Codec;
|
||||
|
||||
private static bool NeedToNormalizeAudioCodec(FFmpegProfile ffmpegProfile, MediaVersion version) =>
|
||||
ffmpegProfile.NormalizeAudioCodec && ffmpegProfile.AudioCodec != version.AudioCodec;
|
||||
private static bool NeedToNormalizeAudioCodec(FFmpegProfile ffmpegProfile, MediaStream audioStream) =>
|
||||
ffmpegProfile.NormalizeAudioCodec && ffmpegProfile.AudioCodec != audioStream.Codec;
|
||||
|
||||
private static IDisplaySize CalculateScaledSize(FFmpegProfile ffmpegProfile, MediaVersion version)
|
||||
{
|
||||
|
||||
@@ -329,12 +329,12 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
return this;
|
||||
}
|
||||
|
||||
public FFmpegProcessBuilder WithFilterComplex()
|
||||
public FFmpegProcessBuilder WithFilterComplex(int videoStreamIndex, int audioStreamIndex)
|
||||
{
|
||||
var videoLabel = "0:V";
|
||||
var audioLabel = "0:a";
|
||||
var videoLabel = $"0:v:{videoStreamIndex}";
|
||||
var audioLabel = $"0:a:{audioStreamIndex}";
|
||||
|
||||
Option<FFmpegComplexFilter> maybeFilter = _complexFilterBuilder.Build();
|
||||
Option<FFmpegComplexFilter> maybeFilter = _complexFilterBuilder.Build(videoStreamIndex, audioStreamIndex);
|
||||
maybeFilter.IfSome(
|
||||
filter =>
|
||||
{
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.FFmpeg;
|
||||
using LanguageExt;
|
||||
@@ -8,12 +9,18 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
{
|
||||
public class FFmpegProcessService
|
||||
{
|
||||
private readonly IFFmpegStreamSelector _ffmpegStreamSelector;
|
||||
private readonly FFmpegPlaybackSettingsCalculator _playbackSettingsCalculator;
|
||||
|
||||
public FFmpegProcessService(FFmpegPlaybackSettingsCalculator ffmpegPlaybackSettingsService) =>
|
||||
public FFmpegProcessService(
|
||||
FFmpegPlaybackSettingsCalculator ffmpegPlaybackSettingsService,
|
||||
IFFmpegStreamSelector ffmpegStreamSelector)
|
||||
{
|
||||
_playbackSettingsCalculator = ffmpegPlaybackSettingsService;
|
||||
_ffmpegStreamSelector = ffmpegStreamSelector;
|
||||
}
|
||||
|
||||
public Process ForPlayoutItem(
|
||||
public async Task<Process> ForPlayoutItem(
|
||||
string ffmpegPath,
|
||||
bool saveReports,
|
||||
Channel channel,
|
||||
@@ -22,10 +29,15 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
DateTimeOffset start,
|
||||
DateTimeOffset now)
|
||||
{
|
||||
MediaStream videoStream = await _ffmpegStreamSelector.SelectVideoStream(channel, version);
|
||||
MediaStream audioStream = await _ffmpegStreamSelector.SelectAudioStream(channel, version);
|
||||
|
||||
FFmpegPlaybackSettings playbackSettings = _playbackSettingsCalculator.CalculateSettings(
|
||||
channel.StreamingMode,
|
||||
channel.FFmpegProfile,
|
||||
version,
|
||||
videoStream,
|
||||
audioStream,
|
||||
start,
|
||||
now);
|
||||
|
||||
@@ -36,7 +48,7 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
.WithFormatFlags(playbackSettings.FormatFlags)
|
||||
.WithRealtimeOutput(playbackSettings.RealtimeOutput)
|
||||
.WithSeek(playbackSettings.StreamSeek)
|
||||
.WithInputCodec(path, playbackSettings.HardwareAcceleration, version.VideoCodec);
|
||||
.WithInputCodec(path, playbackSettings.HardwareAcceleration, videoStream.Codec);
|
||||
|
||||
playbackSettings.ScaledSize.Match(
|
||||
scaledSize =>
|
||||
@@ -51,7 +63,8 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
}
|
||||
|
||||
builder = builder
|
||||
.WithAlignedAudio(playbackSettings.AudioDuration).WithFilterComplex();
|
||||
.WithAlignedAudio(playbackSettings.AudioDuration)
|
||||
.WithFilterComplex(videoStream.Index, audioStream.Index);
|
||||
},
|
||||
() =>
|
||||
{
|
||||
@@ -61,19 +74,19 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
.WithDeinterlace(playbackSettings.Deinterlace)
|
||||
.WithBlackBars(channel.FFmpegProfile.Resolution)
|
||||
.WithAlignedAudio(playbackSettings.AudioDuration)
|
||||
.WithFilterComplex();
|
||||
.WithFilterComplex(videoStream.Index, audioStream.Index);
|
||||
}
|
||||
else if (playbackSettings.Deinterlace)
|
||||
{
|
||||
builder = builder.WithDeinterlace(playbackSettings.Deinterlace)
|
||||
.WithAlignedAudio(playbackSettings.AudioDuration)
|
||||
.WithFilterComplex();
|
||||
.WithFilterComplex(videoStream.Index, audioStream.Index);
|
||||
}
|
||||
else
|
||||
{
|
||||
builder = builder
|
||||
.WithAlignedAudio(playbackSettings.AudioDuration)
|
||||
.WithFilterComplex();
|
||||
.WithFilterComplex(videoStream.Index, audioStream.Index);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -121,7 +134,7 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
.WithFormatFlags(playbackSettings.FormatFlags)
|
||||
.WithRealtimeOutput(playbackSettings.RealtimeOutput)
|
||||
.WithInfiniteLoop()
|
||||
.WithConcat($"{scheme}://{host}/ffmpeg/concat/{channel.Number}")
|
||||
.WithConcat($"http://localhost:8409/ffmpeg/concat/{channel.Number}")
|
||||
.WithMetadata(channel)
|
||||
.WithFormat("mpegts")
|
||||
.WithPipe()
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.FFmpeg;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using LanguageExt;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ErsatzTV.Core.FFmpeg
|
||||
{
|
||||
public class FFmpegStreamSelector : IFFmpegStreamSelector
|
||||
{
|
||||
private readonly IConfigElementRepository _configElementRepository;
|
||||
private readonly ILogger<FFmpegStreamSelector> _logger;
|
||||
|
||||
public FFmpegStreamSelector(
|
||||
ILogger<FFmpegStreamSelector> logger,
|
||||
IConfigElementRepository configElementRepository)
|
||||
{
|
||||
_logger = logger;
|
||||
_configElementRepository = configElementRepository;
|
||||
}
|
||||
|
||||
public Task<MediaStream> SelectVideoStream(Channel channel, MediaVersion version) =>
|
||||
version.Streams.First(s => s.MediaStreamKind == MediaStreamKind.Video).AsTask();
|
||||
|
||||
public async Task<MediaStream> SelectAudioStream(Channel channel, MediaVersion version)
|
||||
{
|
||||
var audioStreams = version.Streams.Filter(s => s.MediaStreamKind == MediaStreamKind.Audio).ToList();
|
||||
|
||||
string language = (channel.PreferredLanguageCode ?? string.Empty).ToLowerInvariant();
|
||||
if (string.IsNullOrWhiteSpace(language))
|
||||
{
|
||||
_logger.LogDebug("Channel {Number} has no preferred language code", channel.Number);
|
||||
Option<string> maybeDefaultLanguage = await _configElementRepository.GetValue<string>(
|
||||
ConfigElementKey.FFmpegPreferredLanguageCode);
|
||||
maybeDefaultLanguage.Match(
|
||||
lang => language = lang.ToLowerInvariant(),
|
||||
() =>
|
||||
{
|
||||
_logger.LogDebug("FFmpeg has no preferred language code; falling back to {Code}", "eng");
|
||||
language = "eng";
|
||||
});
|
||||
}
|
||||
|
||||
var correctLanguage = audioStreams.Filter(
|
||||
s => string.Equals(
|
||||
s.Language,
|
||||
language,
|
||||
StringComparison.InvariantCultureIgnoreCase)).ToList();
|
||||
if (correctLanguage.Any())
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Found {Count} audio streams with preferred language code {Code}; selecting stream with most channels",
|
||||
correctLanguage.Count,
|
||||
language);
|
||||
|
||||
return correctLanguage.OrderByDescending(s => s.Channels).Head();
|
||||
}
|
||||
|
||||
_logger.LogDebug(
|
||||
"Unable to find audio stream with preferred language code {Code}; selecting stream with most channels",
|
||||
language);
|
||||
|
||||
return audioStreams.OrderByDescending(s => s.Channels).Head();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core.Domain;
|
||||
|
||||
namespace ErsatzTV.Core.Interfaces.FFmpeg
|
||||
{
|
||||
public interface IFFmpegStreamSelector
|
||||
{
|
||||
Task<MediaStream> SelectVideoStream(Channel channel, MediaVersion version);
|
||||
Task<MediaStream> SelectAudioStream(Channel channel, MediaVersion version);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Threading.Tasks;
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using LanguageExt;
|
||||
|
||||
@@ -6,6 +7,6 @@ namespace ErsatzTV.Core.Interfaces.Metadata
|
||||
{
|
||||
public interface IMovieFolderScanner
|
||||
{
|
||||
Task<Either<BaseError, Unit>> ScanFolder(LibraryPath libraryPath, string ffprobePath);
|
||||
Task<Either<BaseError, Unit>> ScanFolder(LibraryPath libraryPath, string ffprobePath, DateTimeOffset lastScan);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Threading.Tasks;
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using LanguageExt;
|
||||
|
||||
@@ -6,6 +7,6 @@ namespace ErsatzTV.Core.Interfaces.Metadata
|
||||
{
|
||||
public interface ITelevisionFolderScanner
|
||||
{
|
||||
Task<Either<BaseError, Unit>> ScanFolder(LibraryPath libraryPath, string ffprobePath);
|
||||
Task<Either<BaseError, Unit>> ScanFolder(LibraryPath libraryPath, string ffprobePath, DateTimeOffset lastScan);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace ErsatzTV.Core.Interfaces.Plex
|
||||
{
|
||||
public interface IPlexPathReplacementService
|
||||
{
|
||||
Task<string> GetReplacementPlexPath(int libraryPathId, string path);
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,6 @@ namespace ErsatzTV.Core.Interfaces.Repositories
|
||||
{
|
||||
Task<Option<MediaItem>> Get(int id);
|
||||
Task<List<MediaItem>> GetAll();
|
||||
Task<List<MediaItem>> Search(string searchString);
|
||||
Task<bool> Update(MediaItem mediaItem);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Threading.Tasks;
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using LanguageExt;
|
||||
|
||||
@@ -11,10 +12,13 @@ namespace ErsatzTV.Core.Interfaces.Repositories
|
||||
Task<bool> RemoveStudio(Studio studio);
|
||||
Task<bool> Update(Domain.Metadata metadata);
|
||||
Task<bool> Add(Domain.Metadata metadata);
|
||||
Task<bool> UpdateLocalStatistics(MediaVersion mediaVersion);
|
||||
Task<bool> UpdatePlexStatistics(MediaVersion mediaVersion);
|
||||
Task<bool> UpdateLocalStatistics(int mediaVersionId, MediaVersion incoming, bool updateVersion = true);
|
||||
Task<bool> UpdatePlexStatistics(int mediaVersionId, MediaVersion incoming);
|
||||
Task<Unit> UpdateArtworkPath(Artwork artwork);
|
||||
Task<Unit> AddArtwork(Domain.Metadata metadata, Artwork artwork);
|
||||
Task<Unit> RemoveArtwork(Domain.Metadata metadata, ArtworkKind artworkKind);
|
||||
Task<Unit> MarkAsUpdated(ShowMetadata metadata, DateTime dateUpdated);
|
||||
Task<Unit> MarkAsUpdated(SeasonMetadata metadata, DateTime dateUpdated);
|
||||
Task<Unit> MarkAsUpdated(MovieMetadata metadata, DateTime dateUpdated);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace ErsatzTV.Core.Interfaces.Runtime
|
||||
{
|
||||
public interface IRuntimeInfo
|
||||
{
|
||||
[SuppressMessage("ReSharper", "InconsistentNaming")]
|
||||
bool IsOSPlatform(OSPlatform osPlatform);
|
||||
}
|
||||
}
|
||||
@@ -32,7 +32,7 @@ namespace ErsatzTV.Core.Iptv
|
||||
xml.WriteStartElement("tv");
|
||||
xml.WriteAttributeString("generator-info-name", "ersatztv");
|
||||
|
||||
foreach (Channel channel in _channels.OrderBy(c => c.Number))
|
||||
foreach (Channel channel in _channels.OrderBy(c => decimal.Parse(c.Number)))
|
||||
{
|
||||
xml.WriteStartElement("channel");
|
||||
xml.WriteAttributeString("id", channel.Number);
|
||||
@@ -48,7 +48,7 @@ namespace ErsatzTV.Core.Iptv
|
||||
.HeadOrNone()
|
||||
.Match(
|
||||
artwork => $"{_scheme}://{_host}/iptv/logos/{artwork.Path}",
|
||||
() => $"{_scheme}://{_host}/images/ersatztv-500.png");
|
||||
() => $"{_scheme}://{_host}/iptv/images/ersatztv-500.png");
|
||||
xml.WriteAttributeString("src", logo);
|
||||
xml.WriteEndElement(); // icon
|
||||
|
||||
@@ -122,7 +122,7 @@ namespace ErsatzTV.Core.Iptv
|
||||
.Filter(a => a.ArtworkKind == ArtworkKind.Poster)
|
||||
.HeadOrNone()
|
||||
.Match(
|
||||
artwork => $"{_scheme}://{_host}/artwork/posters/{artwork.Path}",
|
||||
artwork => $"{_scheme}://{_host}/iptv/artwork/posters/{artwork.Path}",
|
||||
() => string.Empty);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(poster))
|
||||
@@ -161,7 +161,7 @@ namespace ErsatzTV.Core.Iptv
|
||||
.Filter(a => a.ArtworkKind == ArtworkKind.Poster)
|
||||
.HeadOrNone()
|
||||
.Match(
|
||||
artwork => $"{_scheme}://{_host}/artwork/posters/{artwork.Path}",
|
||||
artwork => $"{_scheme}://{_host}/iptv/artwork/posters/{artwork.Path}",
|
||||
() => string.Empty);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(poster))
|
||||
|
||||
@@ -33,7 +33,7 @@ namespace ErsatzTV.Core.Iptv
|
||||
.HeadOrNone()
|
||||
.Match(
|
||||
artwork => $"{_scheme}://{_host}/iptv/logos/{artwork.Path}",
|
||||
() => $"{_scheme}://{_host}/images/ersatztv-500.png");
|
||||
() => $"{_scheme}://{_host}/iptv/images/ersatztv-500.png");
|
||||
|
||||
string shortUniqueId = Convert.ToBase64String(channel.UniqueId.ToByteArray())
|
||||
.TrimEnd('=')
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Security.Cryptography;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Images;
|
||||
@@ -15,8 +14,6 @@ namespace ErsatzTV.Core.Metadata
|
||||
{
|
||||
public abstract class LocalFolderScanner
|
||||
{
|
||||
private static readonly SHA1CryptoServiceProvider Crypto;
|
||||
|
||||
public static readonly List<string> VideoFileExtensions = new()
|
||||
{
|
||||
".mpg", ".mp2", ".mpeg", ".mpe", ".mpv", ".ogg", ".mp4",
|
||||
@@ -50,8 +47,6 @@ namespace ErsatzTV.Core.Metadata
|
||||
private readonly ILogger _logger;
|
||||
private readonly IMetadataRepository _metadataRepository;
|
||||
|
||||
static LocalFolderScanner() => Crypto = new SHA1CryptoServiceProvider();
|
||||
|
||||
protected LocalFolderScanner(
|
||||
ILocalFileSystem localFileSystem,
|
||||
ILocalStatisticsProvider localStatisticsProvider,
|
||||
@@ -82,7 +77,7 @@ namespace ErsatzTV.Core.Metadata
|
||||
|
||||
string path = version.MediaFiles.Head().Path;
|
||||
|
||||
if (version.DateUpdated < _localFileSystem.GetLastWriteTime(path))
|
||||
if (version.DateUpdated < _localFileSystem.GetLastWriteTime(path) || !version.Streams.Any())
|
||||
{
|
||||
_logger.LogDebug("Refreshing {Attribute} for {Path}", "Statistics", path);
|
||||
Either<BaseError, bool> refreshResult =
|
||||
|
||||
@@ -113,7 +113,12 @@ namespace ErsatzTV.Core.Metadata
|
||||
existing.Plot = metadata.Plot;
|
||||
existing.Tagline = metadata.Tagline;
|
||||
existing.Title = metadata.Title;
|
||||
existing.DateAdded = metadata.DateAdded;
|
||||
|
||||
if (existing.DateAdded == DateTime.MinValue)
|
||||
{
|
||||
existing.DateAdded = metadata.DateAdded;
|
||||
}
|
||||
|
||||
existing.DateUpdated = metadata.DateUpdated;
|
||||
existing.MetadataKind = metadata.MetadataKind;
|
||||
existing.OriginalTitle = metadata.OriginalTitle;
|
||||
@@ -143,11 +148,18 @@ namespace ErsatzTV.Core.Metadata
|
||||
Optional(movie.MovieMetadata).Flatten().HeadOrNone().Match(
|
||||
async existing =>
|
||||
{
|
||||
var updated = false;
|
||||
|
||||
existing.Outline = metadata.Outline;
|
||||
existing.Plot = metadata.Plot;
|
||||
existing.Tagline = metadata.Tagline;
|
||||
existing.Title = metadata.Title;
|
||||
existing.DateAdded = metadata.DateAdded;
|
||||
|
||||
if (existing.DateAdded == DateTime.MinValue)
|
||||
{
|
||||
existing.DateAdded = metadata.DateAdded;
|
||||
}
|
||||
|
||||
existing.DateUpdated = metadata.DateUpdated;
|
||||
existing.MetadataKind = metadata.MetadataKind;
|
||||
existing.OriginalTitle = metadata.OriginalTitle;
|
||||
@@ -161,28 +173,40 @@ namespace ErsatzTV.Core.Metadata
|
||||
.ToList())
|
||||
{
|
||||
existing.Genres.Remove(genre);
|
||||
await _metadataRepository.RemoveGenre(genre);
|
||||
if (await _metadataRepository.RemoveGenre(genre))
|
||||
{
|
||||
updated = true;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (Genre genre in metadata.Genres.Filter(g => existing.Genres.All(g2 => g2.Name != g.Name))
|
||||
.ToList())
|
||||
{
|
||||
existing.Genres.Add(genre);
|
||||
await _movieRepository.AddGenre(existing, genre);
|
||||
if (await _movieRepository.AddGenre(existing, genre))
|
||||
{
|
||||
updated = true;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (Tag tag in existing.Tags.Filter(t => metadata.Tags.All(t2 => t2.Name != t.Name))
|
||||
.ToList())
|
||||
{
|
||||
existing.Tags.Remove(tag);
|
||||
await _metadataRepository.RemoveTag(tag);
|
||||
if (await _metadataRepository.RemoveTag(tag))
|
||||
{
|
||||
updated = true;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (Tag tag in metadata.Tags.Filter(t => existing.Tags.All(t2 => t2.Name != t.Name))
|
||||
.ToList())
|
||||
{
|
||||
existing.Tags.Add(tag);
|
||||
await _movieRepository.AddTag(existing, tag);
|
||||
if (await _movieRepository.AddTag(existing, tag))
|
||||
{
|
||||
updated = true;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (Studio studio in existing.Studios
|
||||
@@ -190,7 +214,10 @@ namespace ErsatzTV.Core.Metadata
|
||||
.ToList())
|
||||
{
|
||||
existing.Studios.Remove(studio);
|
||||
await _metadataRepository.RemoveStudio(studio);
|
||||
if (await _metadataRepository.RemoveStudio(studio))
|
||||
{
|
||||
updated = true;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (Studio studio in metadata.Studios
|
||||
@@ -198,10 +225,13 @@ namespace ErsatzTV.Core.Metadata
|
||||
.ToList())
|
||||
{
|
||||
existing.Studios.Add(studio);
|
||||
await _movieRepository.AddStudio(existing, studio);
|
||||
if (await _movieRepository.AddStudio(existing, studio))
|
||||
{
|
||||
updated = true;
|
||||
}
|
||||
}
|
||||
|
||||
return await _metadataRepository.Update(existing);
|
||||
return await _metadataRepository.Update(existing) || updated;
|
||||
},
|
||||
async () =>
|
||||
{
|
||||
@@ -218,11 +248,18 @@ namespace ErsatzTV.Core.Metadata
|
||||
Optional(show.ShowMetadata).Flatten().HeadOrNone().Match(
|
||||
async existing =>
|
||||
{
|
||||
var updated = false;
|
||||
|
||||
existing.Outline = metadata.Outline;
|
||||
existing.Plot = metadata.Plot;
|
||||
existing.Tagline = metadata.Tagline;
|
||||
existing.Title = metadata.Title;
|
||||
existing.DateAdded = metadata.DateAdded;
|
||||
|
||||
if (existing.DateAdded == DateTime.MinValue)
|
||||
{
|
||||
existing.DateAdded = metadata.DateAdded;
|
||||
}
|
||||
|
||||
existing.DateUpdated = metadata.DateUpdated;
|
||||
existing.MetadataKind = metadata.MetadataKind;
|
||||
existing.OriginalTitle = metadata.OriginalTitle;
|
||||
@@ -236,28 +273,40 @@ namespace ErsatzTV.Core.Metadata
|
||||
.ToList())
|
||||
{
|
||||
existing.Genres.Remove(genre);
|
||||
await _metadataRepository.RemoveGenre(genre);
|
||||
if (await _metadataRepository.RemoveGenre(genre))
|
||||
{
|
||||
updated = true;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (Genre genre in metadata.Genres.Filter(g => existing.Genres.All(g2 => g2.Name != g.Name))
|
||||
.ToList())
|
||||
{
|
||||
existing.Genres.Add(genre);
|
||||
await _televisionRepository.AddGenre(existing, genre);
|
||||
if (await _televisionRepository.AddGenre(existing, genre))
|
||||
{
|
||||
updated = true;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (Tag tag in existing.Tags.Filter(t => metadata.Tags.All(t2 => t2.Name != t.Name))
|
||||
.ToList())
|
||||
{
|
||||
existing.Tags.Remove(tag);
|
||||
await _metadataRepository.RemoveTag(tag);
|
||||
if (await _metadataRepository.RemoveTag(tag))
|
||||
{
|
||||
updated = true;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (Tag tag in metadata.Tags.Filter(t => existing.Tags.All(t2 => t2.Name != t.Name))
|
||||
.ToList())
|
||||
{
|
||||
existing.Tags.Add(tag);
|
||||
await _televisionRepository.AddTag(existing, tag);
|
||||
if (await _televisionRepository.AddTag(existing, tag))
|
||||
{
|
||||
updated = true;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (Studio studio in existing.Studios
|
||||
@@ -265,7 +314,10 @@ namespace ErsatzTV.Core.Metadata
|
||||
.ToList())
|
||||
{
|
||||
existing.Studios.Remove(studio);
|
||||
await _metadataRepository.RemoveStudio(studio);
|
||||
if (await _metadataRepository.RemoveStudio(studio))
|
||||
{
|
||||
updated = true;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (Studio studio in metadata.Studios
|
||||
@@ -273,10 +325,13 @@ namespace ErsatzTV.Core.Metadata
|
||||
.ToList())
|
||||
{
|
||||
existing.Studios.Add(studio);
|
||||
await _televisionRepository.AddStudio(existing, studio);
|
||||
if (await _televisionRepository.AddStudio(existing, studio))
|
||||
{
|
||||
updated = true;
|
||||
}
|
||||
}
|
||||
|
||||
return await _metadataRepository.Update(existing);
|
||||
return await _metadataRepository.Update(existing) || updated;
|
||||
},
|
||||
async () =>
|
||||
{
|
||||
@@ -332,6 +387,7 @@ namespace ErsatzTV.Core.Metadata
|
||||
nfo => new ShowMetadata
|
||||
{
|
||||
MetadataKind = MetadataKind.Sidecar,
|
||||
DateAdded = DateTime.UtcNow,
|
||||
DateUpdated = File.GetLastWriteTimeUtc(nfoFileName),
|
||||
Title = nfo.Title,
|
||||
Plot = nfo.Plot,
|
||||
@@ -364,6 +420,7 @@ namespace ErsatzTV.Core.Metadata
|
||||
var metadata = new EpisodeMetadata
|
||||
{
|
||||
MetadataKind = MetadataKind.Sidecar,
|
||||
DateAdded = DateTime.UtcNow,
|
||||
DateUpdated = File.GetLastWriteTimeUtc(nfoFileName),
|
||||
Title = nfo.Title,
|
||||
ReleaseDate = GetAired(0, nfo.Aired),
|
||||
@@ -390,6 +447,7 @@ namespace ErsatzTV.Core.Metadata
|
||||
nfo => new MovieMetadata
|
||||
{
|
||||
MetadataKind = MetadataKind.Sidecar,
|
||||
DateAdded = DateTime.UtcNow,
|
||||
DateUpdated = File.GetLastWriteTimeUtc(nfoFileName),
|
||||
Title = nfo.Title,
|
||||
Year = nfo.Year,
|
||||
|
||||
@@ -44,7 +44,7 @@ namespace ErsatzTV.Core.Metadata
|
||||
return await maybeProbe.Match(
|
||||
async ffprobe =>
|
||||
{
|
||||
MediaVersion version = ProjectToMediaVersion(ffprobe);
|
||||
MediaVersion version = ProjectToMediaVersion(filePath, ffprobe);
|
||||
bool result = await ApplyVersionUpdate(mediaItem, version, filePath);
|
||||
return Right<BaseError, bool>(result);
|
||||
},
|
||||
@@ -68,18 +68,9 @@ namespace ErsatzTV.Core.Metadata
|
||||
|
||||
bool durationChange = mediaItemVersion.Duration != version.Duration;
|
||||
|
||||
mediaItemVersion.DateUpdated = _localFileSystem.GetLastWriteTime(filePath);
|
||||
mediaItemVersion.Duration = version.Duration;
|
||||
mediaItemVersion.AudioCodec = version.AudioCodec;
|
||||
mediaItemVersion.SampleAspectRatio = version.SampleAspectRatio;
|
||||
mediaItemVersion.DisplayAspectRatio = version.DisplayAspectRatio;
|
||||
mediaItemVersion.Width = version.Width;
|
||||
mediaItemVersion.Height = version.Height;
|
||||
mediaItemVersion.VideoCodec = version.VideoCodec;
|
||||
mediaItemVersion.VideoProfile = version.VideoProfile;
|
||||
mediaItemVersion.VideoScanKind = version.VideoScanKind;
|
||||
version.DateUpdated = _localFileSystem.GetLastWriteTime(filePath);
|
||||
|
||||
return await _metadataRepository.UpdateLocalStatistics(mediaItemVersion) && durationChange;
|
||||
return await _metadataRepository.UpdateLocalStatistics(mediaItemVersion.Id, version) && durationChange;
|
||||
}
|
||||
|
||||
private Task<Either<BaseError, FFprobe>> GetProbeOutput(string ffprobePath, string filePath)
|
||||
@@ -117,7 +108,7 @@ namespace ErsatzTV.Core.Metadata
|
||||
});
|
||||
}
|
||||
|
||||
private MediaVersion ProjectToMediaVersion(FFprobe probeOutput) =>
|
||||
private MediaVersion ProjectToMediaVersion(string path, FFprobe probeOutput) =>
|
||||
Optional(probeOutput)
|
||||
.Filter(json => json?.format != null && json.streams != null)
|
||||
.ToValidation<BaseError>("Unable to parse ffprobe output")
|
||||
@@ -125,14 +116,47 @@ namespace ErsatzTV.Core.Metadata
|
||||
.Match(
|
||||
json =>
|
||||
{
|
||||
var duration = TimeSpan.FromSeconds(double.Parse(json.format.duration));
|
||||
var version = new MediaVersion
|
||||
{ Name = "Main", DateAdded = DateTime.UtcNow, Streams = new List<MediaStream>() };
|
||||
|
||||
var version = new MediaVersion { Name = "Main", Duration = duration };
|
||||
|
||||
FFprobeStream audioStream = json.streams.FirstOrDefault(s => s.codec_type == "audio");
|
||||
if (audioStream != null)
|
||||
if (double.TryParse(json.format.duration, out double duration))
|
||||
{
|
||||
version.AudioCodec = audioStream.codec_name;
|
||||
var seconds = TimeSpan.FromSeconds(duration);
|
||||
version.Duration = seconds;
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Media item at {Path} has a missing or invalid duration {Duration} and will cause scheduling issues",
|
||||
path,
|
||||
json.format.duration);
|
||||
}
|
||||
|
||||
foreach (FFprobeStream audioStream in json.streams.Filter(s => s.codec_type == "audio"))
|
||||
{
|
||||
var stream = new MediaStream
|
||||
{
|
||||
MediaVersionId = version.Id,
|
||||
MediaStreamKind = MediaStreamKind.Audio,
|
||||
Index = audioStream.index,
|
||||
Codec = audioStream.codec_name,
|
||||
Profile = (audioStream.profile ?? string.Empty).ToLowerInvariant(),
|
||||
Channels = audioStream.channels
|
||||
};
|
||||
|
||||
if (audioStream.disposition is not null)
|
||||
{
|
||||
stream.Default = audioStream.disposition.@default == 1;
|
||||
stream.Forced = audioStream.disposition.forced == 1;
|
||||
}
|
||||
|
||||
if (audioStream.tags is not null)
|
||||
{
|
||||
stream.Language = audioStream.tags.language;
|
||||
stream.Title = audioStream.tags.title;
|
||||
}
|
||||
|
||||
version.Streams.Add(stream);
|
||||
}
|
||||
|
||||
FFprobeStream videoStream = json.streams.FirstOrDefault(s => s.codec_type == "video");
|
||||
@@ -142,14 +166,54 @@ namespace ErsatzTV.Core.Metadata
|
||||
version.DisplayAspectRatio = videoStream.display_aspect_ratio;
|
||||
version.Width = videoStream.width;
|
||||
version.Height = videoStream.height;
|
||||
version.VideoCodec = videoStream.codec_name;
|
||||
version.VideoProfile = (videoStream.profile ?? string.Empty).ToLowerInvariant();
|
||||
version.VideoScanKind = ScanKindFromFieldOrder(videoStream.field_order);
|
||||
|
||||
var stream = new MediaStream
|
||||
{
|
||||
MediaVersionId = version.Id,
|
||||
MediaStreamKind = MediaStreamKind.Video,
|
||||
Index = videoStream.index,
|
||||
Codec = videoStream.codec_name,
|
||||
Profile = (videoStream.profile ?? string.Empty).ToLowerInvariant()
|
||||
};
|
||||
|
||||
if (videoStream.disposition is not null)
|
||||
{
|
||||
stream.Default = videoStream.disposition.@default == 1;
|
||||
stream.Forced = videoStream.disposition.forced == 1;
|
||||
}
|
||||
|
||||
version.Streams.Add(stream);
|
||||
}
|
||||
|
||||
foreach (FFprobeStream subtitleStream in json.streams.Filter(s => s.codec_type == "subtitle"))
|
||||
{
|
||||
var stream = new MediaStream
|
||||
{
|
||||
MediaVersionId = version.Id,
|
||||
MediaStreamKind = MediaStreamKind.Subtitle,
|
||||
Index = subtitleStream.index,
|
||||
Codec = subtitleStream.codec_name
|
||||
};
|
||||
|
||||
if (subtitleStream.disposition is not null)
|
||||
{
|
||||
stream.Default = subtitleStream.disposition.@default == 1;
|
||||
stream.Forced = subtitleStream.disposition.forced == 1;
|
||||
}
|
||||
|
||||
if (subtitleStream.tags is not null)
|
||||
{
|
||||
stream.Language = subtitleStream.tags.language;
|
||||
}
|
||||
|
||||
version.Streams.Add(stream);
|
||||
}
|
||||
|
||||
return version;
|
||||
},
|
||||
_ => new MediaVersion { Name = "Main" });
|
||||
_ => new MediaVersion
|
||||
{ Name = "Main", DateAdded = DateTime.UtcNow, Streams = new List<MediaStream>() });
|
||||
|
||||
private VideoScanKind ScanKindFromFieldOrder(string fieldOrder) =>
|
||||
fieldOrder?.ToLowerInvariant() switch
|
||||
@@ -164,17 +228,24 @@ namespace ErsatzTV.Core.Metadata
|
||||
|
||||
public record FFprobeFormat(string duration);
|
||||
|
||||
public record FFprobeDisposition(int @default, int forced);
|
||||
|
||||
public record FFProbeTags(string language, string title);
|
||||
|
||||
public record FFprobeStream(
|
||||
int index,
|
||||
string codec_name,
|
||||
string profile,
|
||||
string codec_type,
|
||||
int channels,
|
||||
int width,
|
||||
int height,
|
||||
string sample_aspect_ratio,
|
||||
string display_aspect_ratio,
|
||||
string field_order,
|
||||
string r_frame_rate);
|
||||
string r_frame_rate,
|
||||
FFprobeDisposition disposition,
|
||||
FFProbeTags tags);
|
||||
// ReSharper restore InconsistentNaming
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,7 +42,10 @@ namespace ErsatzTV.Core.Metadata
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<Either<BaseError, Unit>> ScanFolder(LibraryPath libraryPath, string ffprobePath)
|
||||
public async Task<Either<BaseError, Unit>> ScanFolder(
|
||||
LibraryPath libraryPath,
|
||||
string ffprobePath,
|
||||
DateTimeOffset lastScan)
|
||||
{
|
||||
if (!_localFileSystem.IsLibraryPathAccessible(libraryPath))
|
||||
{
|
||||
@@ -76,6 +79,11 @@ namespace ErsatzTV.Core.Metadata
|
||||
continue;
|
||||
}
|
||||
|
||||
if (_localFileSystem.GetLastWriteTime(movieFolder) < lastScan)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (string file in allFiles.OrderBy(identity))
|
||||
{
|
||||
// TODO: figure out how to rebuild playlists
|
||||
@@ -210,6 +218,14 @@ namespace ErsatzTV.Core.Metadata
|
||||
ext => new[] { $"{segment}.{ext}", Path.GetFileNameWithoutExtension(path) + $"-{segment}.{ext}" })
|
||||
.Map(f => Path.Combine(folder, f));
|
||||
Option<string> result = possibleMoviePosters.Filter(p => _localFileSystem.FileExists(p)).HeadOrNone();
|
||||
if (result.IsNone && artworkKind == ArtworkKind.Poster)
|
||||
{
|
||||
IEnumerable<string> possibleFolderPosters = ImageFileExtensions.Collect(
|
||||
ext => new[] { $"folder.{ext}" })
|
||||
.Map(f => Path.Combine(folder, f));
|
||||
result = possibleFolderPosters.Filter(p => _localFileSystem.FileExists(p)).HeadOrNone();
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,7 +45,10 @@ namespace ErsatzTV.Core.Metadata
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<Either<BaseError, Unit>> ScanFolder(LibraryPath libraryPath, string ffprobePath)
|
||||
public async Task<Either<BaseError, Unit>> ScanFolder(
|
||||
LibraryPath libraryPath,
|
||||
string ffprobePath,
|
||||
DateTimeOffset lastScan)
|
||||
{
|
||||
if (!_localFileSystem.IsLibraryPathAccessible(libraryPath))
|
||||
{
|
||||
@@ -77,7 +80,7 @@ namespace ErsatzTV.Core.Metadata
|
||||
await _searchIndex.UpdateItems(new List<MediaItem> { result.Item });
|
||||
}
|
||||
|
||||
await ScanSeasons(libraryPath, ffprobePath, result.Item, showFolder);
|
||||
await ScanSeasons(libraryPath, ffprobePath, result.Item, showFolder, lastScan);
|
||||
},
|
||||
_ => Task.FromResult(Unit.Default));
|
||||
}
|
||||
@@ -113,7 +116,8 @@ namespace ErsatzTV.Core.Metadata
|
||||
LibraryPath libraryPath,
|
||||
string ffprobePath,
|
||||
Show show,
|
||||
string showFolder)
|
||||
string showFolder,
|
||||
DateTimeOffset lastScan)
|
||||
{
|
||||
foreach (string seasonFolder in _localFileSystem.ListSubdirectories(showFolder).Filter(ShouldIncludeFolder)
|
||||
.OrderBy(identity))
|
||||
@@ -127,7 +131,7 @@ namespace ErsatzTV.Core.Metadata
|
||||
.BindT(season => UpdatePoster(season, seasonFolder));
|
||||
|
||||
await maybeSeason.Match(
|
||||
season => ScanEpisodes(libraryPath, ffprobePath, season, seasonFolder),
|
||||
season => ScanEpisodes(libraryPath, ffprobePath, season, seasonFolder, lastScan),
|
||||
_ => Task.FromResult(Unit.Default));
|
||||
});
|
||||
}
|
||||
@@ -139,8 +143,14 @@ namespace ErsatzTV.Core.Metadata
|
||||
LibraryPath libraryPath,
|
||||
string ffprobePath,
|
||||
Season season,
|
||||
string seasonPath)
|
||||
string seasonPath,
|
||||
DateTimeOffset lastScan)
|
||||
{
|
||||
if (_localFileSystem.GetLastWriteTime(seasonPath) < lastScan)
|
||||
{
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
foreach (string file in _localFileSystem.ListFiles(seasonPath)
|
||||
.Filter(f => VideoFileExtensions.Contains(Path.GetExtension(f))).OrderBy(identity))
|
||||
{
|
||||
@@ -319,15 +329,16 @@ namespace ErsatzTV.Core.Metadata
|
||||
|
||||
private Option<string> LocateArtworkForShow(string showFolder, ArtworkKind artworkKind)
|
||||
{
|
||||
string segment = artworkKind switch
|
||||
string[] segments = artworkKind switch
|
||||
{
|
||||
ArtworkKind.Poster => "poster",
|
||||
ArtworkKind.FanArt => "fanart",
|
||||
ArtworkKind.Poster => new[] { "poster", "folder" },
|
||||
ArtworkKind.FanArt => new[] { "fanart" },
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(artworkKind))
|
||||
};
|
||||
|
||||
return ImageFileExtensions
|
||||
.Map(ext => $"{segment}.{ext}")
|
||||
.Map(ext => segments.Map(segment => $"{segment}.{ext}"))
|
||||
.Flatten()
|
||||
.Map(f => Path.Combine(showFolder, f))
|
||||
.Filter(s => _localFileSystem.FileExists(s))
|
||||
.HeadOrNone();
|
||||
|
||||
@@ -105,8 +105,7 @@ namespace ErsatzTV.Core.Plex
|
||||
MediaVersion existingVersion = existing.MediaVersions.Head();
|
||||
MediaVersion incomingVersion = incoming.MediaVersions.Head();
|
||||
|
||||
if (incomingVersion.DateUpdated > existingVersion.DateUpdated ||
|
||||
string.IsNullOrWhiteSpace(existingVersion.SampleAspectRatio))
|
||||
if (incomingVersion.DateUpdated > existingVersion.DateUpdated || !existingVersion.Streams.Any())
|
||||
{
|
||||
Either<BaseError, MediaVersion> maybeStatistics =
|
||||
await _plexServerApiClient.GetStatistics(incoming.Key.Split("/").Last(), connection, token);
|
||||
@@ -114,11 +113,11 @@ namespace ErsatzTV.Core.Plex
|
||||
await maybeStatistics.Match(
|
||||
async mediaVersion =>
|
||||
{
|
||||
existingVersion.SampleAspectRatio = mediaVersion.SampleAspectRatio ?? "1:1";
|
||||
existingVersion.SampleAspectRatio = mediaVersion.SampleAspectRatio;
|
||||
existingVersion.VideoScanKind = mediaVersion.VideoScanKind;
|
||||
existingVersion.DateUpdated = incomingVersion.DateUpdated;
|
||||
existingVersion.DateUpdated = mediaVersion.DateUpdated;
|
||||
|
||||
await _metadataRepository.UpdatePlexStatistics(existingVersion);
|
||||
await _metadataRepository.UpdatePlexStatistics(existingVersion.Id, mediaVersion);
|
||||
},
|
||||
_ => Task.CompletedTask);
|
||||
}
|
||||
@@ -189,6 +188,8 @@ namespace ErsatzTV.Core.Plex
|
||||
}
|
||||
}
|
||||
|
||||
await _metadataRepository.MarkAsUpdated(existingMetadata, incomingMetadata.DateUpdated);
|
||||
|
||||
// TODO: update other metadata?
|
||||
}
|
||||
|
||||
@@ -207,6 +208,7 @@ namespace ErsatzTV.Core.Plex
|
||||
{
|
||||
await UpdateArtworkIfNeeded(existingMetadata, incomingMetadata, ArtworkKind.Poster);
|
||||
await UpdateArtworkIfNeeded(existingMetadata, incomingMetadata, ArtworkKind.FanArt);
|
||||
await _metadataRepository.MarkAsUpdated(existingMetadata, incomingMetadata.DateUpdated);
|
||||
}
|
||||
|
||||
return result;
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Plex;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Interfaces.Runtime;
|
||||
using LanguageExt;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ErsatzTV.Core.Plex
|
||||
{
|
||||
public class PlexPathReplacementService : IPlexPathReplacementService
|
||||
{
|
||||
private readonly ILogger<PlexPathReplacementService> _logger;
|
||||
private readonly IMediaSourceRepository _mediaSourceRepository;
|
||||
private readonly IRuntimeInfo _runtimeInfo;
|
||||
|
||||
public PlexPathReplacementService(
|
||||
IMediaSourceRepository mediaSourceRepository,
|
||||
IRuntimeInfo runtimeInfo,
|
||||
ILogger<PlexPathReplacementService> logger)
|
||||
{
|
||||
_mediaSourceRepository = mediaSourceRepository;
|
||||
_runtimeInfo = runtimeInfo;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<string> GetReplacementPlexPath(int libraryPathId, string path)
|
||||
{
|
||||
List<PlexPathReplacement> replacements =
|
||||
await _mediaSourceRepository.GetPlexPathReplacementsByLibraryId(libraryPathId);
|
||||
Option<PlexPathReplacement> maybeReplacement = replacements
|
||||
.SingleOrDefault(
|
||||
r =>
|
||||
{
|
||||
string separatorChar = IsWindows(r.PlexMediaSource) ? @"\" : @"/";
|
||||
string prefix = r.PlexPath.EndsWith(separatorChar) ? r.PlexPath : r.PlexPath + separatorChar;
|
||||
return path.StartsWith(prefix);
|
||||
});
|
||||
return maybeReplacement.Match(
|
||||
replacement =>
|
||||
{
|
||||
string finalPath = path.Replace(replacement.PlexPath, replacement.LocalPath);
|
||||
if (IsWindows(replacement.PlexMediaSource) && !_runtimeInfo.IsOSPlatform(OSPlatform.Windows))
|
||||
{
|
||||
finalPath = finalPath.Replace(@"\", @"/");
|
||||
}
|
||||
else if (!IsWindows(replacement.PlexMediaSource) && _runtimeInfo.IsOSPlatform(OSPlatform.Windows))
|
||||
{
|
||||
finalPath = finalPath.Replace(@"/", @"\");
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"Replacing plex path {PlexPath} with {LocalPath} resulting in {FinalPath}",
|
||||
replacement.PlexPath,
|
||||
replacement.LocalPath,
|
||||
finalPath);
|
||||
return finalPath;
|
||||
},
|
||||
() => path);
|
||||
}
|
||||
|
||||
private static bool IsWindows(PlexMediaSource plexMediaSource) =>
|
||||
plexMediaSource.Platform.ToLowerInvariant() == "windows";
|
||||
}
|
||||
}
|
||||
@@ -131,7 +131,7 @@ namespace ErsatzTV.Core.Plex
|
||||
result.IsUpdated = true;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
foreach (Studio studio in existingMetadata.Studios
|
||||
.Filter(s => incomingMetadata.Studios.All(s2 => s2.Name != s.Name))
|
||||
.ToList())
|
||||
@@ -153,6 +153,8 @@ namespace ErsatzTV.Core.Plex
|
||||
result.IsUpdated = true;
|
||||
}
|
||||
}
|
||||
|
||||
await _metadataRepository.MarkAsUpdated(existingMetadata, incomingMetadata.DateUpdated);
|
||||
}
|
||||
|
||||
return result;
|
||||
@@ -170,6 +172,7 @@ namespace ErsatzTV.Core.Plex
|
||||
{
|
||||
await UpdateArtworkIfNeeded(existingMetadata, incomingMetadata, ArtworkKind.Poster);
|
||||
await UpdateArtworkIfNeeded(existingMetadata, incomingMetadata, ArtworkKind.FanArt);
|
||||
await _metadataRepository.MarkAsUpdated(existingMetadata, incomingMetadata.DateUpdated);
|
||||
}
|
||||
|
||||
return result;
|
||||
@@ -235,6 +238,7 @@ namespace ErsatzTV.Core.Plex
|
||||
if (incomingMetadata.DateUpdated > existingMetadata.DateUpdated)
|
||||
{
|
||||
await UpdateArtworkIfNeeded(existingMetadata, incomingMetadata, ArtworkKind.Poster);
|
||||
await _metadataRepository.MarkAsUpdated(existingMetadata, incomingMetadata.DateUpdated);
|
||||
}
|
||||
|
||||
return existing;
|
||||
@@ -297,8 +301,7 @@ namespace ErsatzTV.Core.Plex
|
||||
MediaVersion existingVersion = existing.MediaVersions.Head();
|
||||
MediaVersion incomingVersion = incoming.MediaVersions.Head();
|
||||
|
||||
if (incomingVersion.DateUpdated > existingVersion.DateUpdated ||
|
||||
string.IsNullOrWhiteSpace(existingVersion.SampleAspectRatio))
|
||||
if (incomingVersion.DateUpdated > existingVersion.DateUpdated || !existingVersion.Streams.Any())
|
||||
{
|
||||
Either<BaseError, MediaVersion> maybeStatistics =
|
||||
await _plexServerApiClient.GetStatistics(incoming.Key.Split("/").Last(), connection, token);
|
||||
@@ -306,11 +309,11 @@ namespace ErsatzTV.Core.Plex
|
||||
await maybeStatistics.Match(
|
||||
async mediaVersion =>
|
||||
{
|
||||
existingVersion.SampleAspectRatio = mediaVersion.SampleAspectRatio ?? "1:1";
|
||||
existingVersion.SampleAspectRatio = mediaVersion.SampleAspectRatio;
|
||||
existingVersion.VideoScanKind = mediaVersion.VideoScanKind;
|
||||
existingVersion.DateUpdated = incomingVersion.DateUpdated;
|
||||
existingVersion.DateUpdated = mediaVersion.DateUpdated;
|
||||
|
||||
await _metadataRepository.UpdatePlexStatistics(existingVersion);
|
||||
await _metadataRepository.UpdatePlexStatistics(existingVersion.Id, mediaVersion);
|
||||
},
|
||||
_ => Task.CompletedTask);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace ErsatzTV.Infrastructure.Data.Configurations
|
||||
{
|
||||
public class MediaStreamConfiguration : IEntityTypeConfiguration<MediaStream>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<MediaStream> builder) => builder.ToTable("MediaStream");
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,11 @@ namespace ErsatzTV.Infrastructure.Data.Configurations
|
||||
.WithOne(f => f.MediaVersion)
|
||||
.HasForeignKey(f => f.MediaVersionId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
builder.HasMany(v => v.Streams)
|
||||
.WithOne(s => s.MediaVersion)
|
||||
.HasForeignKey(s => s.MediaVersionId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Data;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core.Domain;
|
||||
@@ -12,14 +11,10 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
{
|
||||
public class MediaItemRepository : IMediaItemRepository
|
||||
{
|
||||
private readonly IDbConnection _dbConnection;
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||
|
||||
public MediaItemRepository(IDbContextFactory<TvContext> dbContextFactory, IDbConnection dbConnection)
|
||||
{
|
||||
public MediaItemRepository(IDbContextFactory<TvContext> dbContextFactory) =>
|
||||
_dbContextFactory = dbContextFactory;
|
||||
_dbConnection = dbConnection;
|
||||
}
|
||||
|
||||
public async Task<Option<MediaItem>> Get(int id)
|
||||
{
|
||||
@@ -37,27 +32,6 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
return await context.MediaItems.ToListAsync();
|
||||
}
|
||||
|
||||
public Task<List<MediaItem>> Search(string searchString) =>
|
||||
// TODO: fix this when we need to search
|
||||
// IQueryable<TelevisionEpisodeMediaItem> episodeData =
|
||||
// from c in _dbContext.TelevisionEpisodeMediaItems.Include(c => c.LibraryPath) select c;
|
||||
//
|
||||
// if (!string.IsNullOrEmpty(searchString))
|
||||
// {
|
||||
// episodeData = episodeData.Where(c => EF.Functions.Like(c.Metadata.Title, $"%{searchString}%"));
|
||||
// }
|
||||
//
|
||||
// IQueryable<Movie> movieData =
|
||||
// from c in _dbContext.Movies.Include(c => c.LibraryPath) select c;
|
||||
//
|
||||
// // if (!string.IsNullOrEmpty(searchString))
|
||||
// // {
|
||||
// // movieData = movieData.Where(c => EF.Functions.Like(c.Metadata.Title, $"%{searchString}%"));
|
||||
// // }
|
||||
//
|
||||
// return episodeData.OfType<MediaItem>().Concat(movieData.OfType<MediaItem>()).ToListAsync();
|
||||
new List<MediaItem>().AsTask();
|
||||
|
||||
public async Task<bool> Update(MediaItem mediaItem)
|
||||
{
|
||||
await using TvContext context = _dbContextFactory.CreateDbContext();
|
||||
|
||||
@@ -135,6 +135,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
inner join PlexPathReplacement ppr on ppr.PlexMediaSourceId = l.MediaSourceId
|
||||
where lp.Id = {0}",
|
||||
plexLibraryPathId)
|
||||
.Include(ppr => ppr.PlexMediaSource)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
@@ -158,8 +159,20 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
List<PlexConnection> toDelete)
|
||||
{
|
||||
await _dbConnection.ExecuteAsync(
|
||||
@"UPDATE PlexMediaSource SET ProductVersion = @ProductVersion, ServerName = @ServerName WHERE Id = @Id",
|
||||
new { plexMediaSource.ProductVersion, plexMediaSource.ServerName, plexMediaSource.Id });
|
||||
@"UPDATE PlexMediaSource SET
|
||||
ProductVersion = @ProductVersion,
|
||||
Platform = @Platform,
|
||||
PlatformVersion = @PlatformVersion,
|
||||
ServerName = @ServerName
|
||||
WHERE Id = @Id",
|
||||
new
|
||||
{
|
||||
plexMediaSource.ProductVersion,
|
||||
plexMediaSource.Platform,
|
||||
plexMediaSource.PlatformVersion,
|
||||
plexMediaSource.ServerName,
|
||||
plexMediaSource.Id
|
||||
});
|
||||
|
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||
|
||||
@@ -196,6 +209,10 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
{
|
||||
add.MediaSourceId = plexMediaSourceId;
|
||||
dbContext.Entry(add).State = EntityState.Added;
|
||||
foreach (LibraryPath path in add.Paths)
|
||||
{
|
||||
dbContext.Entry(path).State = EntityState.Added;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (PlexLibrary delete in toDelete)
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
using System.Data;
|
||||
using System;
|
||||
using System.Data;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Dapper;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using LanguageExt;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using static LanguageExt.Prelude;
|
||||
|
||||
namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
{
|
||||
@@ -43,15 +46,66 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
return await dbContext.SaveChangesAsync() > 0;
|
||||
}
|
||||
|
||||
public async Task<bool> UpdateLocalStatistics(MediaVersion mediaVersion)
|
||||
public async Task<bool> UpdateLocalStatistics(
|
||||
int mediaVersionId,
|
||||
MediaVersion incoming,
|
||||
bool updateVersion = true)
|
||||
{
|
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||
dbContext.Entry(mediaVersion).State = EntityState.Modified;
|
||||
return await dbContext.SaveChangesAsync() > 0;
|
||||
Option<MediaVersion> maybeVersion = await dbContext.MediaVersions
|
||||
.Include(v => v.Streams)
|
||||
.OrderBy(v => v.Id)
|
||||
.SingleOrDefaultAsync(v => v.Id == mediaVersionId)
|
||||
.Map(Optional);
|
||||
|
||||
return await maybeVersion.Match(
|
||||
async existing =>
|
||||
{
|
||||
if (updateVersion)
|
||||
{
|
||||
existing.DateUpdated = incoming.DateUpdated;
|
||||
existing.Duration = incoming.Duration;
|
||||
existing.SampleAspectRatio = incoming.SampleAspectRatio;
|
||||
existing.DisplayAspectRatio = incoming.DisplayAspectRatio;
|
||||
existing.Width = incoming.Width;
|
||||
existing.Height = incoming.Height;
|
||||
existing.VideoScanKind = incoming.VideoScanKind;
|
||||
}
|
||||
|
||||
var toAdd = incoming.Streams.Filter(s => existing.Streams.All(es => es.Index != s.Index)).ToList();
|
||||
var toRemove = existing.Streams.Filter(es => incoming.Streams.All(s => s.Index != es.Index))
|
||||
.ToList();
|
||||
var toUpdate = incoming.Streams.Except(toAdd).ToList();
|
||||
|
||||
// add
|
||||
existing.Streams.AddRange(toAdd);
|
||||
|
||||
// remove
|
||||
existing.Streams.RemoveAll(s => toRemove.Contains(s));
|
||||
|
||||
// update
|
||||
foreach (MediaStream incomingStream in toUpdate)
|
||||
{
|
||||
MediaStream existingStream = existing.Streams.First(s => s.Index == incomingStream.Index);
|
||||
|
||||
existingStream.Codec = incomingStream.Codec;
|
||||
existingStream.Profile = incomingStream.Profile;
|
||||
existingStream.MediaStreamKind = incomingStream.MediaStreamKind;
|
||||
existingStream.Language = incomingStream.Language;
|
||||
existingStream.Channels = incomingStream.Channels;
|
||||
existingStream.Title = incomingStream.Title;
|
||||
existingStream.Default = incomingStream.Default;
|
||||
existingStream.Forced = incomingStream.Forced;
|
||||
}
|
||||
|
||||
return await dbContext.SaveChangesAsync() > 0;
|
||||
},
|
||||
() => Task.FromResult(false));
|
||||
}
|
||||
|
||||
public Task<bool> UpdatePlexStatistics(MediaVersion mediaVersion) =>
|
||||
_dbConnection.ExecuteAsync(
|
||||
public async Task<bool> UpdatePlexStatistics(int mediaVersionId, MediaVersion incoming)
|
||||
{
|
||||
bool updatedVersion = await _dbConnection.ExecuteAsync(
|
||||
@"UPDATE MediaVersion SET
|
||||
SampleAspectRatio = @SampleAspectRatio,
|
||||
VideoScanKind = @VideoScanKind,
|
||||
@@ -59,12 +113,15 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
WHERE Id = @MediaVersionId",
|
||||
new
|
||||
{
|
||||
mediaVersion.SampleAspectRatio,
|
||||
mediaVersion.VideoScanKind,
|
||||
mediaVersion.DateUpdated,
|
||||
MediaVersionId = mediaVersion.Id
|
||||
incoming.SampleAspectRatio,
|
||||
incoming.VideoScanKind,
|
||||
incoming.DateUpdated,
|
||||
MediaVersionId = mediaVersionId
|
||||
}).Map(result => result > 0);
|
||||
|
||||
return await UpdateLocalStatistics(mediaVersionId, incoming, false) || updatedVersion;
|
||||
}
|
||||
|
||||
public Task<Unit> UpdateArtworkPath(Artwork artwork) =>
|
||||
_dbConnection.ExecuteAsync(
|
||||
"UPDATE Artwork SET Path = @Path, DateUpdated = @DateUpdated WHERE Id = @Id",
|
||||
@@ -109,6 +166,21 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
OR ShowMetadataId = @Id OR SeasonMetadataId = @Id OR EpisodeMetadataId = @Id)",
|
||||
new { ArtworkKind = artworkKind, metadata.Id }).ToUnit();
|
||||
|
||||
public Task<Unit> MarkAsUpdated(ShowMetadata metadata, DateTime dateUpdated) =>
|
||||
_dbConnection.ExecuteAsync(
|
||||
@"UPDATE ShowMetadata SET DateUpdated = @DateUpdated WHERE Id = @Id",
|
||||
new { DateUpdated = dateUpdated, metadata.Id }).ToUnit();
|
||||
|
||||
public Task<Unit> MarkAsUpdated(SeasonMetadata metadata, DateTime dateUpdated) =>
|
||||
_dbConnection.ExecuteAsync(
|
||||
@"UPDATE SeasonMetadata SET DateUpdated = @DateUpdated WHERE Id = @Id",
|
||||
new { DateUpdated = dateUpdated, metadata.Id }).ToUnit();
|
||||
|
||||
public Task<Unit> MarkAsUpdated(MovieMetadata metadata, DateTime dateUpdated) =>
|
||||
_dbConnection.ExecuteAsync(
|
||||
@"UPDATE MovieMetadata SET DateUpdated = @DateUpdated WHERE Id = @Id",
|
||||
new { DateUpdated = dateUpdated, metadata.Id }).ToUnit();
|
||||
|
||||
public Task<bool> RemoveGenre(Genre genre) =>
|
||||
_dbConnection.ExecuteAsync("DELETE FROM Genre WHERE Id = @GenreId", new { GenreId = genre.Id })
|
||||
.Map(result => result > 0);
|
||||
|
||||
@@ -64,6 +64,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
.ThenInclude(lp => lp.Library)
|
||||
.Include(i => i.MediaVersions)
|
||||
.ThenInclude(mv => mv.MediaFiles)
|
||||
.Include(i => i.MediaVersions)
|
||||
.ThenInclude(mv => mv.Streams)
|
||||
.OrderBy(i => i.MediaVersions.First().MediaFiles.First().Path)
|
||||
.SingleOrDefaultAsync(i => i.MediaVersions.First().MediaFiles.First().Path == path);
|
||||
|
||||
@@ -91,6 +93,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
.ThenInclude(mm => mm.Artwork)
|
||||
.Include(i => i.MediaVersions)
|
||||
.ThenInclude(mv => mv.MediaFiles)
|
||||
.Include(i => i.MediaVersions)
|
||||
.ThenInclude(mv => mv.Streams)
|
||||
.Include(i => i.LibraryPath)
|
||||
.ThenInclude(lp => lp.Library)
|
||||
.OrderBy(i => i.Key)
|
||||
@@ -223,7 +227,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
MediaFiles = new List<MediaFile>
|
||||
{
|
||||
new() { Path = path }
|
||||
}
|
||||
},
|
||||
Streams = new List<MediaStream>()
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -256,7 +261,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return BaseError.New(ex.Message);
|
||||
return BaseError.New(ex.ToString());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,8 +57,14 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
.ThenInclude(mi => (mi as Episode).MediaVersions)
|
||||
.ThenInclude(mv => mv.MediaFiles)
|
||||
.Include(i => i.MediaItem)
|
||||
.ThenInclude(mi => (mi as Episode).MediaVersions)
|
||||
.ThenInclude(mv => mv.Streams)
|
||||
.Include(i => i.MediaItem)
|
||||
.ThenInclude(mi => (mi as Movie).MediaVersions)
|
||||
.ThenInclude(mv => mv.MediaFiles)
|
||||
.Include(i => i.MediaItem)
|
||||
.ThenInclude(mi => (mi as Movie).MediaVersions)
|
||||
.ThenInclude(mv => mv.Streams)
|
||||
.AsNoTracking()
|
||||
.SingleOrDefaultAsync()
|
||||
.Map(Optional);
|
||||
|
||||
@@ -215,6 +215,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
.ThenInclude(sm => sm.Genres)
|
||||
.Include(s => s.ShowMetadata)
|
||||
.ThenInclude(sm => sm.Tags)
|
||||
.Include(s => s.ShowMetadata)
|
||||
.ThenInclude(sm => sm.Studios)
|
||||
.Include(s => s.LibraryPath)
|
||||
.ThenInclude(lp => lp.Library)
|
||||
.OrderBy(s => s.Id)
|
||||
@@ -236,6 +238,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
metadata.DateAdded = DateTime.UtcNow;
|
||||
metadata.Genres ??= new List<Genre>();
|
||||
metadata.Tags ??= new List<Tag>();
|
||||
metadata.Studios ??= new List<Studio>();
|
||||
var show = new Show
|
||||
{
|
||||
LibraryPathId = libraryPathId,
|
||||
@@ -282,12 +285,29 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
.ThenInclude(em => em.Artwork)
|
||||
.Include(i => i.MediaVersions)
|
||||
.ThenInclude(mv => mv.MediaFiles)
|
||||
.Include(i => i.MediaVersions)
|
||||
.ThenInclude(mv => mv.Streams)
|
||||
.OrderBy(i => i.MediaVersions.First().MediaFiles.First().Path)
|
||||
.SingleOrDefaultAsync(i => i.MediaVersions.First().MediaFiles.First().Path == path);
|
||||
|
||||
return await maybeExisting.Match(
|
||||
episode => Right<BaseError, Episode>(episode).AsTask(),
|
||||
() => AddEpisode(dbContext, season, libraryPath.Id, path));
|
||||
return await maybeExisting.Match<Task<Either<BaseError, Episode>>>(
|
||||
async episode =>
|
||||
{
|
||||
// move the file to the new season if needed
|
||||
// this can happen when adding NFO metadata to existing content
|
||||
if (episode.SeasonId != season.Id)
|
||||
{
|
||||
episode.SeasonId = season.Id;
|
||||
episode.Season = season;
|
||||
|
||||
await _dbConnection.ExecuteAsync(
|
||||
@"UPDATE Episode SET SeasonId = @SeasonId WHERE Id = @EpisodeId",
|
||||
new { SeasonId = season.Id, EpisodeId = episode.Id });
|
||||
}
|
||||
|
||||
return episode;
|
||||
},
|
||||
async () => await AddEpisode(dbContext, season, libraryPath.Id, path));
|
||||
}
|
||||
|
||||
public Task<IEnumerable<string>> FindEpisodePaths(LibraryPath libraryPath) =>
|
||||
@@ -398,6 +418,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
.ThenInclude(mm => mm.Artwork)
|
||||
.Include(i => i.MediaVersions)
|
||||
.ThenInclude(mv => mv.MediaFiles)
|
||||
.Include(i => i.MediaVersions)
|
||||
.ThenInclude(mv => mv.Streams)
|
||||
.OrderBy(i => i.Key)
|
||||
.SingleOrDefaultAsync(i => i.Key == item.Key);
|
||||
|
||||
@@ -545,7 +567,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
{
|
||||
return BaseError.New("Multi-episode files are not yet supported");
|
||||
}
|
||||
|
||||
|
||||
var episode = new Episode
|
||||
{
|
||||
LibraryPathId = libraryPathId,
|
||||
@@ -566,7 +588,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
MediaFiles = new List<MediaFile>
|
||||
{
|
||||
new() { Path = path }
|
||||
}
|
||||
},
|
||||
Streams = new List<MediaStream>()
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,115 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace ErsatzTV
|
||||
{
|
||||
namespace wms_xamarin
|
||||
{
|
||||
public class HttpLoggingHandler : DelegatingHandler
|
||||
{
|
||||
private readonly string[] types = { "html", "text", "xml", "json", "txt", "x-www-form-urlencoded" };
|
||||
|
||||
public HttpLoggingHandler(HttpMessageHandler innerHandler = null) : base(
|
||||
innerHandler ?? new HttpClientHandler())
|
||||
{
|
||||
}
|
||||
|
||||
protected override async Task<HttpResponseMessage> SendAsync(
|
||||
HttpRequestMessage request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await Task.Delay(1, cancellationToken).ConfigureAwait(false);
|
||||
DateTime start = DateTime.Now;
|
||||
HttpRequestMessage req = request;
|
||||
var msg = $"[{req.RequestUri.PathAndQuery} - Request]";
|
||||
|
||||
Debug.WriteLine($"{msg}========Request Start==========");
|
||||
Debug.WriteLine(
|
||||
$"{msg} {req.Method} {req.RequestUri.PathAndQuery} {req.RequestUri.Scheme}/{req.Version}");
|
||||
Debug.WriteLine($"{msg} Host: {req.RequestUri.Scheme}://{req.RequestUri.Host}");
|
||||
|
||||
foreach (KeyValuePair<string, IEnumerable<string>> header in req.Headers)
|
||||
{
|
||||
Debug.WriteLine($"{msg} {header.Key}: {string.Join(", ", header.Value)}");
|
||||
}
|
||||
|
||||
if (req.Content != null)
|
||||
{
|
||||
foreach (KeyValuePair<string, IEnumerable<string>> header in req.Content.Headers)
|
||||
{
|
||||
Debug.WriteLine($"{msg} {header.Key}: {string.Join(", ", header.Value)}");
|
||||
}
|
||||
|
||||
Debug.WriteLine($"{msg} Content:");
|
||||
|
||||
if (req.Content is StringContent || IsTextBasedContentType(req.Headers) ||
|
||||
IsTextBasedContentType(req.Content.Headers))
|
||||
{
|
||||
string result = await req.Content.ReadAsStringAsync();
|
||||
|
||||
Debug.WriteLine($"{msg} {string.Join("", result.Take(256))}...");
|
||||
}
|
||||
}
|
||||
|
||||
HttpResponseMessage response = await base.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
Debug.WriteLine($"{msg}==========Request End==========");
|
||||
|
||||
msg = $"[{req.RequestUri.PathAndQuery} - Response]";
|
||||
|
||||
Debug.WriteLine($"{msg}=========Response Start=========");
|
||||
|
||||
HttpResponseMessage resp = response;
|
||||
|
||||
Debug.WriteLine(
|
||||
$"{msg} {req.RequestUri.Scheme.ToUpper()}/{resp.Version} {(int) resp.StatusCode} {resp.ReasonPhrase}");
|
||||
|
||||
foreach (KeyValuePair<string, IEnumerable<string>> header in resp.Headers)
|
||||
{
|
||||
Debug.WriteLine($"{msg} {header.Key}: {string.Join(", ", header.Value)}");
|
||||
}
|
||||
|
||||
if (resp.Content != null)
|
||||
{
|
||||
foreach (KeyValuePair<string, IEnumerable<string>> header in resp.Content.Headers)
|
||||
{
|
||||
Debug.WriteLine($"{msg} {header.Key}: {string.Join(", ", header.Value)}");
|
||||
}
|
||||
|
||||
Debug.WriteLine($"{msg} Content:");
|
||||
|
||||
if (resp.Content is StringContent || IsTextBasedContentType(resp.Headers) ||
|
||||
IsTextBasedContentType(resp.Content.Headers))
|
||||
{
|
||||
string result = await resp.Content.ReadAsStringAsync();
|
||||
|
||||
Debug.WriteLine($"{msg} {string.Join("", result.Take(256))}...");
|
||||
}
|
||||
}
|
||||
|
||||
Debug.WriteLine($"{msg} Duration: {DateTime.Now - start}");
|
||||
Debug.WriteLine($"{msg}==========Response End==========");
|
||||
return response;
|
||||
}
|
||||
|
||||
private bool IsTextBasedContentType(HttpHeaders headers)
|
||||
{
|
||||
IEnumerable<string> values;
|
||||
if (!headers.TryGetValues("Content-Type", out values))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
string header = string.Join(" ", values).ToLowerInvariant();
|
||||
|
||||
return types.Any(t => header.Contains(t));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+1755
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,33 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
namespace ErsatzTV.Infrastructure.Migrations
|
||||
{
|
||||
public partial class Add_PlexMediaSourcePlatform : Migration
|
||||
{
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
"Platform",
|
||||
"PlexMediaSource",
|
||||
"TEXT",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
"PlatformVersion",
|
||||
"PlexMediaSource",
|
||||
"TEXT",
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
"Platform",
|
||||
"PlexMediaSource");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
"PlatformVersion",
|
||||
"PlexMediaSource");
|
||||
}
|
||||
}
|
||||
}
|
||||
+1811
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,47 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
namespace ErsatzTV.Infrastructure.Migrations
|
||||
{
|
||||
public partial class Add_MediaStream : Migration
|
||||
{
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
"MediaStream",
|
||||
table => new
|
||||
{
|
||||
Id = table.Column<int>("INTEGER", nullable: false)
|
||||
.Annotation("Sqlite:Autoincrement", true),
|
||||
Index = table.Column<int>("INTEGER", nullable: false),
|
||||
Codec = table.Column<string>("TEXT", nullable: true),
|
||||
Profile = table.Column<string>("TEXT", nullable: true),
|
||||
MediaStreamKind = table.Column<int>("INTEGER", nullable: false),
|
||||
Language = table.Column<string>("TEXT", nullable: true),
|
||||
Channels = table.Column<int>("INTEGER", nullable: false),
|
||||
Title = table.Column<string>("TEXT", nullable: true),
|
||||
Default = table.Column<bool>("INTEGER", nullable: false),
|
||||
Forced = table.Column<bool>("INTEGER", nullable: false),
|
||||
MediaVersionId = table.Column<int>("INTEGER", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_MediaStream", x => x.Id);
|
||||
table.ForeignKey(
|
||||
"FK_MediaStream_MediaVersion_MediaVersionId",
|
||||
x => x.MediaVersionId,
|
||||
"MediaVersion",
|
||||
"Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
"IX_MediaStream_MediaVersionId",
|
||||
"MediaStream",
|
||||
"MediaVersionId");
|
||||
}
|
||||
|
||||
protected override void Down(MigrationBuilder migrationBuilder) =>
|
||||
migrationBuilder.DropTable(
|
||||
"MediaStream");
|
||||
}
|
||||
}
|
||||
Generated
+1814
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,19 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
namespace ErsatzTV.Infrastructure.Migrations
|
||||
{
|
||||
public partial class Add_ChannelPreferredLanguageCode : Migration
|
||||
{
|
||||
protected override void Up(MigrationBuilder migrationBuilder) =>
|
||||
migrationBuilder.AddColumn<string>(
|
||||
"PreferredLanguageCode",
|
||||
"Channel",
|
||||
"TEXT",
|
||||
nullable: true);
|
||||
|
||||
protected override void Down(MigrationBuilder migrationBuilder) =>
|
||||
migrationBuilder.DropColumn(
|
||||
"PreferredLanguageCode",
|
||||
"Channel");
|
||||
}
|
||||
}
|
||||
Generated
+1814
File diff suppressed because it is too large
Load Diff
+14
@@ -0,0 +1,14 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
namespace ErsatzTV.Infrastructure.Migrations
|
||||
{
|
||||
public partial class Reset_LibraryLastScan_MediaStream : Migration
|
||||
{
|
||||
protected override void Up(MigrationBuilder migrationBuilder) =>
|
||||
migrationBuilder.Sql(@"UPDATE Library SET LastScan = '0001-01-01 00:00:00'");
|
||||
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -83,6 +83,9 @@ namespace ErsatzTV.Infrastructure.Migrations
|
||||
b.Property<string>("Number")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("PreferredLanguageCode")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("StreamingMode")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
@@ -419,6 +422,51 @@ namespace ErsatzTV.Infrastructure.Migrations
|
||||
b.ToTable("MediaSource");
|
||||
});
|
||||
|
||||
modelBuilder.Entity(
|
||||
"ErsatzTV.Core.Domain.MediaStream",
|
||||
b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Channels")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Codec")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("Default")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("Forced")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Index")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Language")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("MediaStreamKind")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("MediaVersionId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Profile")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("MediaVersionId");
|
||||
|
||||
b.ToTable("MediaStream");
|
||||
});
|
||||
|
||||
modelBuilder.Entity(
|
||||
"ErsatzTV.Core.Domain.MediaVersion",
|
||||
b =>
|
||||
@@ -1026,6 +1074,12 @@ namespace ErsatzTV.Infrastructure.Migrations
|
||||
b.Property<string>("ClientIdentifier")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Platform")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("PlatformVersion")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ProductVersion")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
@@ -1293,6 +1347,19 @@ namespace ErsatzTV.Infrastructure.Migrations
|
||||
b.Navigation("LibraryPath");
|
||||
});
|
||||
|
||||
modelBuilder.Entity(
|
||||
"ErsatzTV.Core.Domain.MediaStream",
|
||||
b =>
|
||||
{
|
||||
b.HasOne("ErsatzTV.Core.Domain.MediaVersion", "MediaVersion")
|
||||
.WithMany("Streams")
|
||||
.HasForeignKey("MediaVersionId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("MediaVersion");
|
||||
});
|
||||
|
||||
modelBuilder.Entity(
|
||||
"ErsatzTV.Core.Domain.MediaVersion",
|
||||
b =>
|
||||
@@ -1816,7 +1883,14 @@ namespace ErsatzTV.Infrastructure.Migrations
|
||||
|
||||
modelBuilder.Entity("ErsatzTV.Core.Domain.MediaSource", b => { b.Navigation("Libraries"); });
|
||||
|
||||
modelBuilder.Entity("ErsatzTV.Core.Domain.MediaVersion", b => { b.Navigation("MediaFiles"); });
|
||||
modelBuilder.Entity(
|
||||
"ErsatzTV.Core.Domain.MediaVersion",
|
||||
b =>
|
||||
{
|
||||
b.Navigation("MediaFiles");
|
||||
|
||||
b.Navigation("Streams");
|
||||
});
|
||||
|
||||
modelBuilder.Entity(
|
||||
"ErsatzTV.Core.Domain.MovieMetadata",
|
||||
|
||||
@@ -6,6 +6,8 @@ namespace ErsatzTV.Infrastructure.Plex.Models
|
||||
{
|
||||
public string Name { get; set; }
|
||||
public string ProductVersion { get; set; }
|
||||
public string Platform { get; set; }
|
||||
public string PlatformVersion { get; set; }
|
||||
public string ClientIdentifier { get; set; }
|
||||
|
||||
public string AccessToken { get; set; }
|
||||
|
||||
@@ -3,7 +3,14 @@
|
||||
public class PlexStreamResponse
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public int Index { get; set; }
|
||||
public bool Default { get; set; }
|
||||
public bool Forced { get; set; }
|
||||
public string LanguageCode { get; set; }
|
||||
public int StreamType { get; set; }
|
||||
public string Codec { get; set; }
|
||||
public string Profile { get; set; }
|
||||
public int Channels { get; set; }
|
||||
public bool Anamorphic { get; set; }
|
||||
public string PixelAspectRatio { get; set; }
|
||||
public string ScanType { get; set; }
|
||||
|
||||
@@ -227,10 +227,8 @@ namespace ErsatzTV.Infrastructure.Plex
|
||||
Duration = TimeSpan.FromMilliseconds(media.Duration),
|
||||
Width = media.Width,
|
||||
Height = media.Height,
|
||||
AudioCodec = media.AudioCodec,
|
||||
VideoCodec = media.VideoCodec,
|
||||
VideoProfile = media.VideoProfile,
|
||||
// specifically omit sample aspect ratio
|
||||
DateAdded = dateAdded,
|
||||
DateUpdated = lastWriteTime,
|
||||
MediaFiles = new List<MediaFile>
|
||||
{
|
||||
@@ -240,7 +238,8 @@ namespace ErsatzTV.Infrastructure.Plex
|
||||
Key = part.Key,
|
||||
Path = part.File
|
||||
}
|
||||
}
|
||||
},
|
||||
Streams = new List<MediaStream>()
|
||||
};
|
||||
|
||||
var movie = new PlexMovie
|
||||
@@ -255,18 +254,72 @@ namespace ErsatzTV.Infrastructure.Plex
|
||||
|
||||
private Option<MediaVersion> ProjectToMediaVersion(PlexMetadataResponse response)
|
||||
{
|
||||
Option<PlexStreamResponse> maybeStream =
|
||||
response.Media.Head().Part.Head().Stream.Find(s => s.StreamType == 1);
|
||||
return maybeStream.Map(
|
||||
stream => new MediaVersion
|
||||
List<PlexStreamResponse> streams = response.Media.Head().Part.Head().Stream;
|
||||
DateTime dateUpdated = DateTimeOffset.FromUnixTimeSeconds(response.UpdatedAt).DateTime;
|
||||
Option<PlexStreamResponse> maybeVideoStream = streams.Find(s => s.StreamType == 1);
|
||||
return maybeVideoStream.Map(
|
||||
videoStream =>
|
||||
{
|
||||
SampleAspectRatio = stream.PixelAspectRatio,
|
||||
VideoScanKind = stream.ScanType switch
|
||||
var version = new MediaVersion
|
||||
{
|
||||
"interlaced" => VideoScanKind.Interlaced,
|
||||
"progressive" => VideoScanKind.Progressive,
|
||||
_ => VideoScanKind.Unknown
|
||||
SampleAspectRatio = videoStream.PixelAspectRatio ?? "1:1",
|
||||
VideoScanKind = videoStream.ScanType switch
|
||||
{
|
||||
"interlaced" => VideoScanKind.Interlaced,
|
||||
"progressive" => VideoScanKind.Progressive,
|
||||
_ => VideoScanKind.Unknown
|
||||
},
|
||||
Streams = new List<MediaStream>(),
|
||||
DateUpdated = dateUpdated
|
||||
};
|
||||
|
||||
version.Streams.Add(
|
||||
new MediaStream
|
||||
{
|
||||
MediaStreamKind = MediaStreamKind.Video,
|
||||
Index = videoStream.Index,
|
||||
Codec = videoStream.Codec,
|
||||
Profile = (videoStream.Profile ?? string.Empty).ToLowerInvariant(),
|
||||
Default = videoStream.Default,
|
||||
Language = videoStream.LanguageCode,
|
||||
Forced = videoStream.Forced
|
||||
});
|
||||
|
||||
foreach (PlexStreamResponse audioStream in streams.Filter(s => s.StreamType == 2))
|
||||
{
|
||||
var stream = new MediaStream
|
||||
{
|
||||
MediaVersionId = version.Id,
|
||||
MediaStreamKind = MediaStreamKind.Audio,
|
||||
Index = audioStream.Index,
|
||||
Codec = audioStream.Codec,
|
||||
Profile = (audioStream.Profile ?? string.Empty).ToLowerInvariant(),
|
||||
Channels = audioStream.Channels,
|
||||
Default = audioStream.Default,
|
||||
Forced = audioStream.Forced,
|
||||
Language = audioStream.LanguageCode
|
||||
};
|
||||
|
||||
version.Streams.Add(stream);
|
||||
}
|
||||
|
||||
foreach (PlexStreamResponse subtitleStream in streams.Filter(s => s.StreamType == 3))
|
||||
{
|
||||
var stream = new MediaStream
|
||||
{
|
||||
MediaVersionId = version.Id,
|
||||
MediaStreamKind = MediaStreamKind.Subtitle,
|
||||
Index = subtitleStream.Index,
|
||||
Codec = subtitleStream.Codec,
|
||||
Default = subtitleStream.Default,
|
||||
Forced = subtitleStream.Forced,
|
||||
Language = subtitleStream.LanguageCode
|
||||
};
|
||||
|
||||
version.Streams.Add(stream);
|
||||
}
|
||||
|
||||
return version;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -436,10 +489,7 @@ namespace ErsatzTV.Infrastructure.Plex
|
||||
Duration = TimeSpan.FromMilliseconds(media.Duration),
|
||||
Width = media.Width,
|
||||
Height = media.Height,
|
||||
AudioCodec = media.AudioCodec,
|
||||
VideoCodec = media.VideoCodec,
|
||||
VideoProfile = media.VideoProfile,
|
||||
// specifically omit sample aspect ratio
|
||||
DateAdded = dateAdded,
|
||||
DateUpdated = lastWriteTime,
|
||||
MediaFiles = new List<MediaFile>
|
||||
{
|
||||
@@ -449,7 +499,9 @@ namespace ErsatzTV.Infrastructure.Plex
|
||||
Key = part.Key,
|
||||
Path = part.File
|
||||
}
|
||||
}
|
||||
},
|
||||
// specifically omit stream details
|
||||
Streams = new List<MediaStream>()
|
||||
};
|
||||
|
||||
var episode = new PlexEpisode
|
||||
|
||||
@@ -70,6 +70,8 @@ namespace ErsatzTV.Infrastructure.Plex
|
||||
{
|
||||
ServerName = resource.Name,
|
||||
ProductVersion = resource.ProductVersion,
|
||||
Platform = resource.Platform,
|
||||
PlatformVersion = resource.PlatformVersion,
|
||||
ClientIdentifier = resource.ClientIdentifier,
|
||||
Connections = sortedConnections
|
||||
.Map(c => new PlexConnection { Uri = c.Uri }).ToList()
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
using System.Runtime.InteropServices;
|
||||
using ErsatzTV.Core.Interfaces.Runtime;
|
||||
|
||||
namespace ErsatzTV.Infrastructure.Runtime
|
||||
{
|
||||
public class RuntimeInfo : IRuntimeInfo
|
||||
{
|
||||
public bool IsOSPlatform(OSPlatform osPlatform) => RuntimeInformation.IsOSPlatform(osPlatform);
|
||||
}
|
||||
}
|
||||
@@ -49,7 +49,7 @@ namespace ErsatzTV.Infrastructure.Search
|
||||
private readonly ILogger<SearchIndex> _logger;
|
||||
|
||||
private readonly ISearchRepository _searchRepository;
|
||||
|
||||
|
||||
public SearchIndex(
|
||||
ILocalFileSystem localFileSystem,
|
||||
ISearchRepository searchRepository,
|
||||
|
||||
@@ -28,6 +28,7 @@ namespace ErsatzTV.Controllers
|
||||
_httpClientFactory = httpClientFactory;
|
||||
}
|
||||
|
||||
[HttpGet("/iptv/artwork/posters/{fileName}")]
|
||||
[HttpGet("/artwork/posters/{fileName}")]
|
||||
public async Task<IActionResult> GetPoster(string fileName)
|
||||
{
|
||||
@@ -48,6 +49,7 @@ namespace ErsatzTV.Controllers
|
||||
Right: r => new FileContentResult(r.Contents, r.MimeType));
|
||||
}
|
||||
|
||||
[HttpGet("/iptv/artwork/posters/plex/{plexMediaSourceId}/{*path}")]
|
||||
[HttpGet("/artwork/posters/plex/{plexMediaSourceId}/{*path}")]
|
||||
public Task<IActionResult> GetPlexPoster(int plexMediaSourceId, string path) =>
|
||||
GetPlexArtwork(
|
||||
|
||||
@@ -34,6 +34,7 @@
|
||||
<MudSelectItem Value="@profile.Id">@profile.Name</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
<MudTextField Class="mt-3" Label="Preferred Language Code" @bind-Value="_model.PreferredLanguageCode" For="@(() => _model.PreferredLanguageCode)"/>
|
||||
<MudGrid Class="mt-3" Style="align-items: center" Justify="Justify.Center">
|
||||
<MudItem xs="6">
|
||||
<InputFile id="fileInput" OnChange="UploadLogo" hidden/>
|
||||
@@ -90,6 +91,7 @@
|
||||
_model.FFmpegProfileId = channelViewModel.FFmpegProfileId;
|
||||
_model.Logo = channelViewModel.Logo;
|
||||
_model.StreamingMode = channelViewModel.StreamingMode;
|
||||
_model.PreferredLanguageCode = channelViewModel.PreferredLanguageCode;
|
||||
},
|
||||
() => NavigationManager.NavigateTo("404"));
|
||||
}
|
||||
|
||||
@@ -15,9 +15,10 @@
|
||||
<ColGroup>
|
||||
<col style="width: 60px;"/>
|
||||
<col/>
|
||||
<col style="width: 20%"/>
|
||||
<col style="width: 20%"/>
|
||||
<col style="width: 20%"/>
|
||||
<col style="width: 15%"/>
|
||||
<col style="width: 15%"/>
|
||||
<col style="width: 15%"/>
|
||||
<col style="width: 15%"/>
|
||||
<col style="width: 120px;"/>
|
||||
</ColGroup>
|
||||
<HeaderContent>
|
||||
@@ -28,7 +29,8 @@
|
||||
<MudTh>
|
||||
<MudTableSortLabel SortBy="new Func<ChannelViewModel, object>(x => x.Name)">Name</MudTableSortLabel>
|
||||
</MudTh>
|
||||
<MudTh>Streaming Mode</MudTh>
|
||||
<MudTh>Language</MudTh>
|
||||
<MudTh>Mode</MudTh>
|
||||
<MudTh>FFmpeg Profile</MudTh>
|
||||
<MudTh/>
|
||||
</HeaderContent>
|
||||
@@ -41,7 +43,8 @@
|
||||
}
|
||||
</MudTd>
|
||||
<MudTd DataLabel="Name">@context.Name</MudTd>
|
||||
<MudTd DataLabel="Streaming Mode">@context.StreamingMode</MudTd>
|
||||
<MudTd DataLabel="Language">@context.PreferredLanguageCode</MudTd>
|
||||
<MudTd DataLabel="Mode">@(context.StreamingMode == StreamingMode.TransportStream ? "TS" : "HLS")</MudTd>
|
||||
<MudTd DataLabel="FFmpeg Profile">
|
||||
@if (context.StreamingMode == StreamingMode.TransportStream)
|
||||
{
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
@using ErsatzTV.Application.FFmpegProfiles.Commands
|
||||
@using ErsatzTV.Application.FFmpegProfiles.Queries
|
||||
@using Unit = LanguageExt.Unit
|
||||
@using System.Globalization
|
||||
@inject IDialogService Dialog
|
||||
@inject IMediator Mediator
|
||||
@inject ILogger<FFmpeg> Logger
|
||||
@@ -29,6 +30,9 @@
|
||||
}
|
||||
</MudSelect>
|
||||
</MudElement>
|
||||
<MudElement HtmlTag="div" Class="mt-3">
|
||||
<MudTextField T="string" Label="Preferred Language Code" @bind-Value="_ffmpegSettings.PreferredLanguageCode" Validation="@(new Func<string, string>(ValidateLanguageCode))" Required="true" RequiredError="Preferred Language Code is required!"/>
|
||||
</MudElement>
|
||||
<MudElement HtmlTag="div" Class="mt-3">
|
||||
<MudSwitch T="bool"
|
||||
Label="Save troubleshooting reports to disk"
|
||||
@@ -133,6 +137,19 @@
|
||||
|
||||
private static string ValidatePathExists(string path) => !File.Exists(path) ? "Path does not exist" : null;
|
||||
|
||||
private static string ValidateLanguageCode(string languageCode)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(languageCode))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
Option<CultureInfo> culture = CultureInfo.GetCultures(CultureTypes.NeutralCultures)
|
||||
.FirstOrDefault(ci => string.Equals(ci.ThreeLetterISOLanguageName, languageCode, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
return culture.IsNone ? "Preferred language code is invalid" : null;
|
||||
}
|
||||
|
||||
private async Task LoadFFmpegProfilesAsync() =>
|
||||
_ffmpegProfiles = await Mediator.Send(new GetAllFFmpegProfiles());
|
||||
|
||||
|
||||
+6
-1
@@ -14,6 +14,7 @@ using ErsatzTV.Core.Interfaces.Locking;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Plex;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Interfaces.Runtime;
|
||||
using ErsatzTV.Core.Interfaces.Scheduling;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using ErsatzTV.Core.Metadata;
|
||||
@@ -25,6 +26,7 @@ using ErsatzTV.Infrastructure.Data.Repositories;
|
||||
using ErsatzTV.Infrastructure.Images;
|
||||
using ErsatzTV.Infrastructure.Locking;
|
||||
using ErsatzTV.Infrastructure.Plex;
|
||||
using ErsatzTV.Infrastructure.Runtime;
|
||||
using ErsatzTV.Infrastructure.Search;
|
||||
using ErsatzTV.Serialization;
|
||||
using ErsatzTV.Services;
|
||||
@@ -177,7 +179,6 @@ namespace ErsatzTV
|
||||
private void CustomServices(IServiceCollection services)
|
||||
{
|
||||
services.AddSingleton<FFmpegPlaybackSettingsCalculator>();
|
||||
services.AddSingleton<FFmpegProcessService>();
|
||||
services.AddSingleton<IPlexSecretStore, PlexSecretStore>();
|
||||
services.AddSingleton<IPlexTvApiClient, PlexTvApiClient>(); // TODO: does this need to be singleton?
|
||||
services.AddSingleton<IEntityLocker, EntityLocker>();
|
||||
@@ -212,6 +213,10 @@ namespace ErsatzTV
|
||||
services.AddScoped<IPlexTelevisionLibraryScanner, PlexTelevisionLibraryScanner>();
|
||||
services.AddScoped<IPlexServerApiClient, PlexServerApiClient>();
|
||||
services.AddScoped<ISearchIndex, SearchIndex>();
|
||||
services.AddScoped<IRuntimeInfo, RuntimeInfo>();
|
||||
services.AddScoped<IPlexPathReplacementService, PlexPathReplacementService>();
|
||||
services.AddScoped<IFFmpegStreamSelector, FFmpegStreamSelector>();
|
||||
services.AddScoped<FFmpegProcessService>();
|
||||
|
||||
services.AddHostedService<PlexService>();
|
||||
services.AddHostedService<FFmpegLocatorService>();
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.ViewModels;
|
||||
using FluentValidation;
|
||||
|
||||
@@ -13,6 +16,17 @@ namespace ErsatzTV.Validators
|
||||
|
||||
RuleFor(x => x.Name).NotEmpty();
|
||||
RuleFor(x => x.FFmpegProfileId).GreaterThan(0);
|
||||
|
||||
RuleFor(x => x.PreferredLanguageCode)
|
||||
.Must(
|
||||
languageCode => CultureInfo.GetCultures(CultureTypes.NeutralCultures)
|
||||
.Any(
|
||||
ci => string.Equals(
|
||||
ci.ThreeLetterISOLanguageName,
|
||||
languageCode,
|
||||
StringComparison.OrdinalIgnoreCase)))
|
||||
.When(vm => !string.IsNullOrWhiteSpace(vm.PreferredLanguageCode))
|
||||
.WithMessage("Preferred language code is invalid");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ namespace ErsatzTV.ViewModels
|
||||
public string Name { get; set; }
|
||||
public string Number { get; set; }
|
||||
public int FFmpegProfileId { get; set; }
|
||||
public string PreferredLanguageCode { get; set; }
|
||||
public string Logo { get; set; }
|
||||
public StreamingMode StreamingMode { get; set; }
|
||||
|
||||
@@ -19,6 +20,7 @@ namespace ErsatzTV.ViewModels
|
||||
Number,
|
||||
FFmpegProfileId,
|
||||
Logo,
|
||||
PreferredLanguageCode,
|
||||
StreamingMode);
|
||||
|
||||
public CreateChannel ToCreate() =>
|
||||
@@ -27,6 +29,7 @@ namespace ErsatzTV.ViewModels
|
||||
Number,
|
||||
FFmpegProfileId,
|
||||
Logo,
|
||||
PreferredLanguageCode,
|
||||
StreamingMode);
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 4.6 KiB |
Reference in New Issue
Block a user