add preferred language (#109)

* add explicit warning for zero/invalid duration media items

* set dateadded on plex media versions

* add media stream table

* save local media streams to db

* save plex media streams to db

* add preferred language settings (no validation)

* use preferred language if possible

* code cleanup

* proper language code validation

* force scan of all libraries to pull in media streams
This commit is contained in:
Jason Dove
2021-03-28 21:54:48 +00:00
committed by GitHub
parent 51b671dec7
commit d303bc0158
52 changed files with 6265 additions and 181 deletions
+1 -2
View File
@@ -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; }
}
}
+1
View File
@@ -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; }
-9
View File
@@ -1,9 +0,0 @@
namespace ErsatzTV.Core.Domain
{
public enum SourceMode
{
Transcode,
DirectPlay,
DirectPaths
}
}
@@ -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)
{
+4 -4
View File
@@ -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 =>
{
+20 -7
View File
@@ -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);
}
});
@@ -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.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);
}
}
@@ -12,8 +12,8 @@ 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);
+1 -1
View File
@@ -77,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 =
@@ -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
}
}
@@ -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);
}
@@ -301,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);
@@ -310,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);
}