Browse Source

add better check for interlaced content (#2620)

pull/2621/head
Jason Dove 9 months ago committed by GitHub
parent
commit
d0505cd5c5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 5
      CHANGELOG.md
  2. 5
      ErsatzTV.Application/FFmpegProfiles/Commands/UpdateFFmpegSettingsHandler.cs
  3. 1
      ErsatzTV.Application/FFmpegProfiles/FFmpegSettingsViewModel.cs
  4. 5
      ErsatzTV.Application/FFmpegProfiles/Queries/GetFFmpegSettingsHandler.cs
  5. 2
      ErsatzTV.Application/Streaming/Queries/GetPlayoutItemProcessByChannelNumberHandler.cs
  6. 2
      ErsatzTV.Application/Troubleshooting/Commands/PrepareTroubleshootingPlaybackHandler.cs
  7. 1
      ErsatzTV.Core/Domain/ConfigElementKey.cs
  8. 1
      ErsatzTV.Core/Domain/MediaItem/MediaVersion.cs
  9. 75
      ErsatzTV.Core/FFmpeg/FFmpegLibraryProcessService.cs
  10. 5
      ErsatzTV.Core/FFmpeg/MediaItemVideoVersion.cs
  11. 2
      ErsatzTV.Core/Interfaces/FFmpeg/IFFmpegProcessService.cs
  12. 5
      ErsatzTV.Core/Interfaces/Metadata/ILocalStatisticsProvider.cs
  13. 1
      ErsatzTV.Core/Interfaces/Repositories/IMediaItemRepository.cs
  14. 6870
      ErsatzTV.Infrastructure.MySql/Migrations/20251110043500_Add_MediaVersionInterlacedRatio.Designer.cs
  15. 28
      ErsatzTV.Infrastructure.MySql/Migrations/20251110043500_Add_MediaVersionInterlacedRatio.cs
  16. 3
      ErsatzTV.Infrastructure.MySql/Migrations/TvContextModelSnapshot.cs
  17. 6697
      ErsatzTV.Infrastructure.Sqlite/Migrations/20251110043425_Add_MediaVersionInterlacedRatio.Designer.cs
  18. 28
      ErsatzTV.Infrastructure.Sqlite/Migrations/20251110043425_Add_MediaVersionInterlacedRatio.cs
  19. 3
      ErsatzTV.Infrastructure.Sqlite/Migrations/TvContextModelSnapshot.cs
  20. 12
      ErsatzTV.Infrastructure/Data/Repositories/MediaItemRepository.cs
  21. 1
      ErsatzTV.Infrastructure/Data/Repositories/MetadataRepository.cs
  22. 124
      ErsatzTV.Infrastructure/Metadata/LocalStatisticsProvider.cs
  23. 8
      ErsatzTV.Scanner.Tests/Core/FFmpeg/TranscodingTests.cs
  24. 6
      ErsatzTV/Pages/Settings/FFmpegSettings.razor

5
CHANGELOG.md

@ -31,6 +31,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). @@ -31,6 +31,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- AviSynth itself needs to be installed
- Add `Troubleshoot` button to classic schedule list
- This generates JSON representing the entire schedule which can be shared when requested for troubleshooting
- Add **Settings** > **FFmpeg** > **Probe For Interlaced Frames**
- When enabled, this will probe *local content* for interlaced frames on demand (immediately before playback)
- This will be used as a more accurate check for interlaced content
- The result will be cached (only probed once and stored) in the database along with all other media item statistics (e.g. duration)
- This feature will currently ignore content that is not streamed from disk
### Fixed
- Fix HLS Direct playback with Jellyfin 10.11

5
ErsatzTV.Application/FFmpegProfiles/Commands/UpdateFFmpegSettingsHandler.cs

@ -113,6 +113,11 @@ public class UpdateFFmpegSettingsHandler( @@ -113,6 +113,11 @@ public class UpdateFFmpegSettingsHandler(
await workerChannel.WriteAsync(new ExtractEmbeddedSubtitles(Option<int>.None), cancellationToken);
}
await configElementRepository.Upsert(
ConfigElementKey.FFmpegProbeForInterlacedFrames,
request.Settings.ProbeForInterlacedFrames,
cancellationToken);
if (request.Settings.GlobalWatermarkId is not null)
{
await configElementRepository.Upsert(

1
ErsatzTV.Application/FFmpegProfiles/FFmpegSettingsViewModel.cs

@ -10,6 +10,7 @@ public class FFmpegSettingsViewModel @@ -10,6 +10,7 @@ public class FFmpegSettingsViewModel
public string PreferredAudioLanguageCode { get; set; }
public bool UseEmbeddedSubtitles { get; set; }
public bool ExtractEmbeddedSubtitles { get; set; }
public bool ProbeForInterlacedFrames { get; set; }
public bool SaveReports { get; set; }
public int? GlobalWatermarkId { get; set; }
public int? GlobalFallbackFillerId { get; set; }

5
ErsatzTV.Application/FFmpegProfiles/Queries/GetFFmpegSettingsHandler.cs

@ -33,6 +33,10 @@ public class GetFFmpegSettingsHandler(IConfigElementRepository configElementRepo @@ -33,6 +33,10 @@ public class GetFFmpegSettingsHandler(IConfigElementRepository configElementRepo
await configElementRepository.GetValue<bool>(
ConfigElementKey.FFmpegExtractEmbeddedSubtitles,
cancellationToken);
Option<bool> probeForInterlacedFrames =
await configElementRepository.GetValue<bool>(
ConfigElementKey.FFmpegProbeForInterlacedFrames,
cancellationToken);
Option<int> watermark =
await configElementRepository.GetValue<int>(ConfigElementKey.FFmpegGlobalWatermarkId, cancellationToken);
Option<int> fallbackFiller =
@ -62,6 +66,7 @@ public class GetFFmpegSettingsHandler(IConfigElementRepository configElementRepo @@ -62,6 +66,7 @@ public class GetFFmpegSettingsHandler(IConfigElementRepository configElementRepo
SaveReports = await saveReports.IfNoneAsync(false),
UseEmbeddedSubtitles = await useEmbeddedSubtitles.IfNoneAsync(true),
ExtractEmbeddedSubtitles = await extractEmbeddedSubtitles.IfNoneAsync(false),
ProbeForInterlacedFrames = await probeForInterlacedFrames.IfNoneAsync(false),
PreferredAudioLanguageCode = await preferredAudioLanguageCode.IfNoneAsync("eng"),
HlsSegmenterIdleTimeout = await hlsSegmenterIdleTimeout.IfNoneAsync(60),
WorkAheadSegmenterLimit = await workAheadSegmenterLimit.IfNoneAsync(1),

2
ErsatzTV.Application/Streaming/Queries/GetPlayoutItemProcessByChannelNumberHandler.cs

@ -408,7 +408,7 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler< @@ -408,7 +408,7 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
ffprobePath,
saveReports,
channel,
videoVersion,
new MediaItemVideoVersion(playoutItemWithPath.PlayoutItem.MediaItem, videoVersion),
new MediaItemAudioVersion(playoutItemWithPath.PlayoutItem.MediaItem, audioVersion),
videoPath,
audioPath,

2
ErsatzTV.Application/Troubleshooting/Commands/PrepareTroubleshootingPlaybackHandler.cs

@ -224,7 +224,7 @@ public class PrepareTroubleshootingPlaybackHandler( @@ -224,7 +224,7 @@ public class PrepareTroubleshootingPlaybackHandler(
ffprobePath,
true,
channel,
videoVersion,
new MediaItemVideoVersion(mediaItem, videoVersion),
new MediaItemAudioVersion(mediaItem, version),
videoPath,
mediaPath,

1
ErsatzTV.Core/Domain/ConfigElementKey.cs

@ -19,6 +19,7 @@ public class ConfigElementKey @@ -19,6 +19,7 @@ public class ConfigElementKey
public static ConfigElementKey FFmpegSaveReports => new("ffmpeg.save_reports");
public static ConfigElementKey FFmpegUseEmbeddedSubtitles => new("ffmpeg.use_embedded_subtitles");
public static ConfigElementKey FFmpegExtractEmbeddedSubtitles => new("ffmpeg.extract_embedded_subtitles");
public static ConfigElementKey FFmpegProbeForInterlacedFrames => new("ffmpeg.probe_for_interlaced_frames");
public static ConfigElementKey FFmpegPreferredLanguageCode => new("ffmpeg.preferred_language_code");
public static ConfigElementKey FFmpegGlobalWatermarkId => new("ffmpeg.global_watermark_id");
public static ConfigElementKey FFmpegGlobalFallbackFillerId => new("ffmpeg.global_fallback_filler_id");

1
ErsatzTV.Core/Domain/MediaItem/MediaVersion.cs

@ -14,6 +14,7 @@ public class MediaVersion : IDisplaySize @@ -14,6 +14,7 @@ public class MediaVersion : IDisplaySize
public string DisplayAspectRatio { get; set; }
public string RFrameRate { get; set; }
public VideoScanKind VideoScanKind { get; set; }
public double? InterlacedRatio { get; set; }
public DateTime DateAdded { get; set; }
public DateTime DateUpdated { get; set; }
public int Width { get; set; }

75
ErsatzTV.Core/FFmpeg/FFmpegLibraryProcessService.cs

@ -4,7 +4,9 @@ using CliWrap; @@ -4,7 +4,9 @@ using CliWrap;
using CliWrap.Buffered;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Filler;
using ErsatzTV.Core.Extensions;
using ErsatzTV.Core.Interfaces.FFmpeg;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Streaming;
using ErsatzTV.FFmpeg;
@ -26,6 +28,8 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService @@ -26,6 +28,8 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
private readonly IGraphicsElementLoader _graphicsElementLoader;
private readonly IMemoryCache _memoryCache;
private readonly IMpegTsScriptService _mpegTsScriptService;
private readonly ILocalStatisticsProvider _localStatisticsProvider;
private readonly IMediaItemRepository _mediaItemRepository;
private readonly ICustomStreamSelector _customStreamSelector;
private readonly FFmpegProcessService _ffmpegProcessService;
private readonly IFFmpegStreamSelector _ffmpegStreamSelector;
@ -43,6 +47,8 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService @@ -43,6 +47,8 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
IGraphicsElementLoader graphicsElementLoader,
IMemoryCache memoryCache,
IMpegTsScriptService mpegTsScriptService,
ILocalStatisticsProvider localStatisticsProvider,
IMediaItemRepository mediaItemRepository,
ILogger<FFmpegLibraryProcessService> logger)
{
_ffmpegProcessService = ffmpegProcessService;
@ -54,6 +60,8 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService @@ -54,6 +60,8 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
_graphicsElementLoader = graphicsElementLoader;
_memoryCache = memoryCache;
_mpegTsScriptService = mpegTsScriptService;
_localStatisticsProvider = localStatisticsProvider;
_mediaItemRepository = mediaItemRepository;
_logger = logger;
}
@ -62,7 +70,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService @@ -62,7 +70,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
string ffprobePath,
bool saveReports,
Channel channel,
MediaVersion videoVersion,
MediaItemVideoVersion videoVersion,
MediaItemAudioVersion audioVersion,
string videoPath,
string audioPath,
@ -92,7 +100,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService @@ -92,7 +100,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
bool canProxy,
CancellationToken cancellationToken)
{
MediaStream videoStream = await _ffmpegStreamSelector.SelectVideoStream(videoVersion);
MediaStream videoStream = await _ffmpegStreamSelector.SelectVideoStream(videoVersion.MediaVersion);
// we cannot burst live input
hlsRealtime = hlsRealtime || streamInputKind is StreamInputKind.Live;
@ -100,7 +108,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService @@ -100,7 +108,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
FFmpegPlaybackSettings playbackSettings = FFmpegPlaybackSettingsCalculator.CalculateSettings(
channel.StreamingMode,
channel.FFmpegProfile,
videoVersion,
videoVersion.MediaVersion,
videoStream,
start,
now,
@ -214,6 +222,8 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService @@ -214,6 +222,8 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
};
});
ScanKind scanKind = await ProbeScanKind(ffmpegPath, videoVersion.MediaItem, cancellationToken);
var ffmpegVideoStream = new VideoStream(
videoStream.Index,
videoStream.Codec,
@ -224,12 +234,12 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService @@ -224,12 +234,12 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
videoStream.ColorSpace,
videoStream.ColorTransfer,
videoStream.ColorPrimaries),
new FrameSize(videoVersion.Width, videoVersion.Height),
videoVersion.SampleAspectRatio,
videoVersion.DisplayAspectRatio,
videoVersion.RFrameRate,
new FrameSize(videoVersion.MediaVersion.Width, videoVersion.MediaVersion.Height),
videoVersion.MediaVersion.SampleAspectRatio,
videoVersion.MediaVersion.DisplayAspectRatio,
videoVersion.MediaVersion.RFrameRate,
videoPath != audioPath, // still image when paths are different
videoVersion.VideoScanKind == VideoScanKind.Progressive ? ScanKind.Progressive : ScanKind.Interlaced);
scanKind);
var videoInputFile = new VideoInputFile(
videoPath,
@ -455,8 +465,8 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService @@ -455,8 +465,8 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
if (channel.FFmpegProfile.ScalingBehavior is ScalingBehavior.Crop)
{
bool isTooSmallToCrop = videoVersion.Height < channel.FFmpegProfile.Resolution.Height ||
videoVersion.Width < channel.FFmpegProfile.Resolution.Width;
bool isTooSmallToCrop = videoVersion.MediaVersion.Height < channel.FFmpegProfile.Resolution.Height ||
videoVersion.MediaVersion.Width < channel.FFmpegProfile.Resolution.Width;
// if any dimension is smaller than the crop, scale beyond the crop (beyond the target resolution)
if (isTooSmallToCrop)
@ -546,7 +556,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService @@ -546,7 +556,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
ptsOffset,
playbackSettings.ThreadCount,
qsvExtraHardwareFrames,
videoVersion is BackgroundImageMediaVersion { IsSongWithProgress: true },
videoVersion.MediaVersion is BackgroundImageMediaVersion { IsSongWithProgress: true },
false,
GetTonemapAlgorithm(playbackSettings),
channel.UniqueId == Guid.Empty);
@ -584,6 +594,47 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService @@ -584,6 +594,47 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
return new PlayoutItemResult(command, graphicsEngineContext);
}
private async Task<ScanKind> ProbeScanKind(
string ffmpegPath,
MediaItem mediaItem,
CancellationToken cancellationToken)
{
var headVersion = mediaItem.GetHeadVersion();
if (headVersion.VideoScanKind is VideoScanKind.Interlaced)
{
_logger.LogDebug("Container is marked {ScanKind}", headVersion.VideoScanKind);
return ScanKind.Interlaced;
}
// skip probe if disabled
if (!await _configElementRepository.GetValue<bool>(
ConfigElementKey.FFmpegProbeForInterlacedFrames,
cancellationToken).IfNoneAsync(false))
{
_logger.LogDebug("Probe for interlaced frames is disabled");
return ScanKind.Progressive;
}
if (headVersion.InterlacedRatio is null)
{
_logger.LogDebug("Will probe for interlaced frames");
Option<double> maybeInterlacedRatio =
await _localStatisticsProvider.GetInterlacedRatio(ffmpegPath, mediaItem, cancellationToken);
foreach (double ratio in maybeInterlacedRatio)
{
await _mediaItemRepository.SetInterlacedRatio(mediaItem, ratio);
}
}
var result = headVersion.InterlacedRatio > 0.05 ? ScanKind.Interlaced : ScanKind.Progressive;
_logger.LogDebug(
"Content has interlaced ratio of {Ratio} - will consider as {ScanKind}",
headVersion.InterlacedRatio,
result);
return result;
}
public async Task<Command> ForError(
string ffmpegPath,
Channel channel,
@ -847,7 +898,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService @@ -847,7 +898,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
// TODO: save reports?
string defaultScript = await _configElementRepository
.GetValue<string>(ConfigElementKey.FFmpegDefaultMpegTsScript, cancellationToken)
.IfNoneAsync("default");
.IfNoneAsync("Default");
List<MpegTsScript> allScripts = _mpegTsScriptService.GetScripts();
Option<MpegTsScript> maybeScript = Optional(allScripts.Find(s => s.Id == defaultScript));
foreach (var script in maybeScript)

5
ErsatzTV.Core/FFmpeg/MediaItemVideoVersion.cs

@ -0,0 +1,5 @@ @@ -0,0 +1,5 @@
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Core.FFmpeg;
public record MediaItemVideoVersion(MediaItem MediaItem, MediaVersion MediaVersion);

2
ErsatzTV.Core/Interfaces/FFmpeg/IFFmpegProcessService.cs

@ -14,7 +14,7 @@ public interface IFFmpegProcessService @@ -14,7 +14,7 @@ public interface IFFmpegProcessService
string ffprobePath,
bool saveReports,
Channel channel,
MediaVersion videoVersion,
MediaItemVideoVersion videoVersion,
MediaItemAudioVersion audioVersion,
string videoPath,
string audioPath,

5
ErsatzTV.Core/Interfaces/Metadata/ILocalStatisticsProvider.cs

@ -9,4 +9,9 @@ public interface ILocalStatisticsProvider @@ -9,4 +9,9 @@ public interface ILocalStatisticsProvider
Task<Either<BaseError, bool>> RefreshStatistics(string ffmpegPath, string ffprobePath, MediaItem mediaItem);
Either<BaseError, List<SongTag>> GetSongTags(MediaItem mediaItem);
Task<Option<double>> GetInterlacedRatio(
string ffmpegPath,
MediaItem mediaItem,
CancellationToken cancellationToken);
}

1
ErsatzTV.Core/Interfaces/Repositories/IMediaItemRepository.cs

@ -13,4 +13,5 @@ public interface IMediaItemRepository @@ -13,4 +13,5 @@ public interface IMediaItemRepository
Task<Unit> FlagNormal(MediaItem mediaItem);
Task<Either<BaseError, Unit>> DeleteItems(List<int> mediaItemIds);
Task<ImmutableHashSet<string>> GetAllTrashedItems(LibraryPath libraryPath);
Task SetInterlacedRatio(MediaItem mediaItem, double interlacedRatio);
}

6870
ErsatzTV.Infrastructure.MySql/Migrations/20251110043500_Add_MediaVersionInterlacedRatio.Designer.cs generated

File diff suppressed because it is too large Load Diff

28
ErsatzTV.Infrastructure.MySql/Migrations/20251110043500_Add_MediaVersionInterlacedRatio.cs

@ -0,0 +1,28 @@ @@ -0,0 +1,28 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ErsatzTV.Infrastructure.MySql.Migrations
{
/// <inheritdoc />
public partial class Add_MediaVersionInterlacedRatio : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<double>(
name: "InterlacedRatio",
table: "MediaVersion",
type: "double",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "InterlacedRatio",
table: "MediaVersion");
}
}
}

3
ErsatzTV.Infrastructure.MySql/Migrations/TvContextModelSnapshot.cs

@ -1393,6 +1393,9 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations @@ -1393,6 +1393,9 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
b.Property<int?>("ImageId")
.HasColumnType("int");
b.Property<double?>("InterlacedRatio")
.HasColumnType("double");
b.Property<int?>("MovieId")
.HasColumnType("int");

6697
ErsatzTV.Infrastructure.Sqlite/Migrations/20251110043425_Add_MediaVersionInterlacedRatio.Designer.cs generated

File diff suppressed because it is too large Load Diff

28
ErsatzTV.Infrastructure.Sqlite/Migrations/20251110043425_Add_MediaVersionInterlacedRatio.cs

@ -0,0 +1,28 @@ @@ -0,0 +1,28 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ErsatzTV.Infrastructure.Sqlite.Migrations
{
/// <inheritdoc />
public partial class Add_MediaVersionInterlacedRatio : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<double>(
name: "InterlacedRatio",
table: "MediaVersion",
type: "REAL",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "InterlacedRatio",
table: "MediaVersion");
}
}
}

3
ErsatzTV.Infrastructure.Sqlite/Migrations/TvContextModelSnapshot.cs

@ -1326,6 +1326,9 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations @@ -1326,6 +1326,9 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
b.Property<int?>("ImageId")
.HasColumnType("INTEGER");
b.Property<double?>("InterlacedRatio")
.HasColumnType("REAL");
b.Property<int?>("MovieId")
.HasColumnType("INTEGER");

12
ErsatzTV.Infrastructure/Data/Repositories/MediaItemRepository.cs

@ -112,6 +112,18 @@ public class MediaItemRepository : IMediaItemRepository @@ -112,6 +112,18 @@ public class MediaItemRepository : IMediaItemRepository
.Map(list => list.ToImmutableHashSet());
}
public async Task SetInterlacedRatio(MediaItem mediaItem, double interlacedRatio)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
var mediaVersion = mediaItem.GetHeadVersion();
mediaVersion.InterlacedRatio = interlacedRatio;
await dbContext.Connection.ExecuteAsync(
@"UPDATE MediaVersion SET InterlacedRatio = @InterlacedRatio WHERE Id = @Id",
new { mediaVersion.Id, InterlacedRatio = interlacedRatio });
}
public async Task<Unit> FlagNormal(MediaItem mediaItem)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();

1
ErsatzTV.Infrastructure/Data/Repositories/MetadataRepository.cs

@ -137,6 +137,7 @@ public class MetadataRepository(IDbContextFactory<TvContext> dbContextFactory) : @@ -137,6 +137,7 @@ public class MetadataRepository(IDbContextFactory<TvContext> dbContextFactory) :
existing.Width = incoming.Width;
existing.Height = incoming.Height;
existing.VideoScanKind = incoming.VideoScanKind;
existing.InterlacedRatio = incoming.InterlacedRatio;
existing.RFrameRate = incoming.RFrameRate;
}

124
ErsatzTV.Infrastructure/Metadata/LocalStatisticsProvider.cs

@ -18,7 +18,7 @@ using File = TagLib.File; @@ -18,7 +18,7 @@ using File = TagLib.File;
namespace ErsatzTV.Infrastructure.Metadata;
public class LocalStatisticsProvider : ILocalStatisticsProvider
public partial class LocalStatisticsProvider : ILocalStatisticsProvider
{
private readonly IClient _client;
private readonly IHardwareCapabilitiesFactory _hardwareCapabilitiesFactory;
@ -131,6 +131,52 @@ public class LocalStatisticsProvider : ILocalStatisticsProvider @@ -131,6 +131,52 @@ public class LocalStatisticsProvider : ILocalStatisticsProvider
}
}
public async Task<Option<double>> GetInterlacedRatio(
string ffmpegPath,
MediaItem mediaItem,
CancellationToken cancellationToken)
{
try
{
string filePath = await PathForMediaItem(mediaItem);
if (Path.GetExtension(filePath) == ".avs" && !_hardwareCapabilitiesFactory.IsAviSynthInstalled())
{
return Option<double>.None;
}
if (filePath.StartsWith("http", StringComparison.OrdinalIgnoreCase) ||
!_localFileSystem.FileExists(filePath))
{
_logger.LogDebug("Skipping interlaced ratio check for remote content");
return Option<double>.None;
}
var duration = mediaItem.GetDurationForPlayout();
if (duration < TimeSpan.FromSeconds(3))
{
return Option<double>.None;
}
Option<IdetStatistics> maybeStats = await GetIdetOutput(ffmpegPath, filePath, duration / 3);
foreach (var stats in maybeStats)
{
if (stats.TotalFrames == 0)
{
return 0;
}
return (double)stats.TotalInterlacedFrames / stats.TotalInterlacedFrames;
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to check interlaced ratio for media item {Id}", mediaItem.Id);
}
return Option<double>.None;
}
private async Task<Either<BaseError, bool>> RefreshStatistics(
string ffmpegPath,
string ffprobePath,
@ -200,8 +246,7 @@ public class LocalStatisticsProvider : ILocalStatisticsProvider @@ -200,8 +246,7 @@ public class LocalStatisticsProvider : ILocalStatisticsProvider
FFprobe ffprobe = JsonConvert.DeserializeObject<FFprobe>(probe.StandardOutput);
if (ffprobe is not null)
{
const string PATTERN = @"\[SAR\s+([0-9]+:[0-9]+)\s+DAR\s+([0-9]+:[0-9]+)\]";
Match match = Regex.Match(probe.StandardError, PATTERN);
Match match = SarDarRegex().Match(probe.StandardError);
if (match.Success)
{
string sar = match.Groups[1].Value;
@ -234,6 +279,55 @@ public class LocalStatisticsProvider : ILocalStatisticsProvider @@ -234,6 +279,55 @@ public class LocalStatisticsProvider : ILocalStatisticsProvider
return BaseError.New("Unable to deserialize ffprobe output");
}
private async Task<Option<IdetStatistics>> GetIdetOutput(string ffmpegPath, string filePath, TimeSpan seek)
{
string[] arguments =
[
"-hide_banner",
"-ss", $"{seek:c}",
"-i", filePath,
"-vf", "idet",
"-frames:v", "200",
"-an",
"-f", "null", "-"
];
BufferedCommandResult idet = await Cli.Wrap(ffmpegPath)
.WithArguments(arguments)
.WithValidation(CommandResultValidation.None)
.ExecuteBufferedAsync(Encoding.UTF8);
if (idet.ExitCode != 0)
{
_logger.LogInformation(
"FFmpeg idet with arguments {Arguments} exited with code {ExitCode}",
arguments,
idet.ExitCode);
return Option<IdetStatistics>.None;
}
var stats = new IdetStatistics();
var singleMatch = SingleFrameRegex().Match(idet.StandardOutput);
if (singleMatch.Success)
{
stats.SingleTff = int.Parse(singleMatch.Groups[1].Value, NumberFormatInfo.InvariantInfo);
stats.SingleBff = int.Parse(singleMatch.Groups[2].Value, NumberFormatInfo.InvariantInfo);
stats.SingleProgressive = int.Parse(singleMatch.Groups[3].Value, NumberFormatInfo.InvariantInfo);
}
var multiMatch = MultiFrameRegex().Match(idet.StandardOutput);
if (multiMatch.Success)
{
stats.MultiTff = int.Parse(multiMatch.Groups[1].Value, NumberFormatInfo.InvariantInfo);
stats.MultiBff = int.Parse(multiMatch.Groups[2].Value, NumberFormatInfo.InvariantInfo);
stats.MultiProgressive = int.Parse(multiMatch.Groups[3].Value, NumberFormatInfo.InvariantInfo);
}
return stats;
}
private async Task AnalyzeDuration(string ffmpegPath, string path, MediaVersion version)
{
try
@ -366,6 +460,7 @@ public class LocalStatisticsProvider : ILocalStatisticsProvider @@ -366,6 +460,7 @@ public class LocalStatisticsProvider : ILocalStatisticsProvider
version.Width = videoStream.width;
version.Height = videoStream.height;
version.VideoScanKind = ScanKindFromFieldOrder(videoStream.field_order);
version.InterlacedRatio = null;
version.RFrameRate = videoStream.r_frame_rate;
var stream = new MediaStream
@ -600,4 +695,27 @@ public class LocalStatisticsProvider : ILocalStatisticsProvider @@ -600,4 +695,27 @@ public class LocalStatisticsProvider : ILocalStatisticsProvider
null);
}
// ReSharper restore InconsistentNaming
[GeneratedRegex(@"\[SAR\s+([0-9]+:[0-9]+)\s+DAR\s+([0-9]+:[0-9]+)\]")]
private static partial Regex SarDarRegex();
[GeneratedRegex(@"Single frame detection: TFF: (\d+) BFF: (\d+) Progressive: (\d+)")]
private static partial Regex SingleFrameRegex();
[GeneratedRegex(@"Multi frame detection: TFF: (\d+) BFF: (\d+) Progressive: (\d+)")]
private static partial Regex MultiFrameRegex();
private class IdetStatistics
{
public int SingleTff { get; set; }
public int SingleBff { get; set; }
public int SingleProgressive { get; set; }
public int MultiTff { get; set; }
public int MultiBff { get; set; }
public int MultiProgressive { get; set; }
public int TotalInterlacedFrames => SingleTff + SingleBff + MultiTff + MultiBff;
public int TotalProgressiveFrames => SingleProgressive + MultiProgressive;
public int TotalFrames => TotalInterlacedFrames + TotalProgressiveFrames;
}
}

8
ErsatzTV.Scanner.Tests/Core/FFmpeg/TranscodingTests.cs

@ -283,6 +283,8 @@ public class TranscodingTests @@ -283,6 +283,8 @@ public class TranscodingTests
graphicsElementLoader,
MemoryCache,
Substitute.For<IMpegTsScriptService>(),
Substitute.For<ILocalStatisticsProvider>(),
Substitute.For<IMediaItemRepository>(),
LoggerFactory.CreateLogger<FFmpegLibraryProcessService>());
var songVideoGenerator = new SongVideoGenerator(tempFilePool, mockImageCache, service);
@ -375,7 +377,7 @@ public class TranscodingTests @@ -375,7 +377,7 @@ public class TranscodingTests
ExecutableName("ffprobe"),
true,
channel,
videoVersion,
new MediaItemVideoVersion(song, videoVersion),
new MediaItemAudioVersion(song, songVersion),
videoPath,
file,
@ -695,7 +697,7 @@ public class TranscodingTests @@ -695,7 +697,7 @@ public class TranscodingTests
ExecutableName("ffprobe"),
true,
channel,
v,
new MediaItemVideoVersion(null, v),
new MediaItemAudioVersion(null, v),
file,
file,
@ -990,6 +992,8 @@ public class TranscodingTests @@ -990,6 +992,8 @@ public class TranscodingTests
graphicsElementLoader,
MemoryCache,
Substitute.For<IMpegTsScriptService>(),
Substitute.For<ILocalStatisticsProvider>(),
Substitute.For<IMediaItemRepository>(),
LoggerFactory.CreateLogger<FFmpegLibraryProcessService>());
return service;

6
ErsatzTV/Pages/Settings/FFmpegSettings.razor

@ -68,6 +68,12 @@ @@ -68,6 +68,12 @@
</div>
<MudCheckBox @bind-Value="_ffmpegSettings.ExtractEmbeddedSubtitles" Disabled="@(_ffmpegSettings.UseEmbeddedSubtitles == false)" Dense="true"/>
</MudStack>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Probe For Interlaced Frames</MudText>
</div>
<MudCheckBox @bind-Value="_ffmpegSettings.ProbeForInterlacedFrames" Dense="true"/>
</MudStack>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Save Troubleshooting Reports To Disk</MudText>

Loading…
Cancel
Save