Browse Source

add better check for interlaced content

pull/2620/head
Jason Dove 9 months ago
parent
commit
963b06f3e2
No known key found for this signature in database
  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/).
- AviSynth itself needs to be installed - AviSynth itself needs to be installed
- Add `Troubleshoot` button to classic schedule list - Add `Troubleshoot` button to classic schedule list
- This generates JSON representing the entire schedule which can be shared when requested for troubleshooting - 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 ### Fixed
- Fix HLS Direct playback with Jellyfin 10.11 - Fix HLS Direct playback with Jellyfin 10.11

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

@ -113,6 +113,11 @@ public class UpdateFFmpegSettingsHandler(
await workerChannel.WriteAsync(new ExtractEmbeddedSubtitles(Option<int>.None), cancellationToken); 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) if (request.Settings.GlobalWatermarkId is not null)
{ {
await configElementRepository.Upsert( await configElementRepository.Upsert(

1
ErsatzTV.Application/FFmpegProfiles/FFmpegSettingsViewModel.cs

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

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

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

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

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

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

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

1
ErsatzTV.Core/Domain/ConfigElementKey.cs

@ -19,6 +19,7 @@ public class ConfigElementKey
public static ConfigElementKey FFmpegSaveReports => new("ffmpeg.save_reports"); public static ConfigElementKey FFmpegSaveReports => new("ffmpeg.save_reports");
public static ConfigElementKey FFmpegUseEmbeddedSubtitles => new("ffmpeg.use_embedded_subtitles"); public static ConfigElementKey FFmpegUseEmbeddedSubtitles => new("ffmpeg.use_embedded_subtitles");
public static ConfigElementKey FFmpegExtractEmbeddedSubtitles => new("ffmpeg.extract_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 FFmpegPreferredLanguageCode => new("ffmpeg.preferred_language_code");
public static ConfigElementKey FFmpegGlobalWatermarkId => new("ffmpeg.global_watermark_id"); public static ConfigElementKey FFmpegGlobalWatermarkId => new("ffmpeg.global_watermark_id");
public static ConfigElementKey FFmpegGlobalFallbackFillerId => new("ffmpeg.global_fallback_filler_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
public string DisplayAspectRatio { get; set; } public string DisplayAspectRatio { get; set; }
public string RFrameRate { get; set; } public string RFrameRate { get; set; }
public VideoScanKind VideoScanKind { get; set; } public VideoScanKind VideoScanKind { get; set; }
public double? InterlacedRatio { get; set; }
public DateTime DateAdded { get; set; } public DateTime DateAdded { get; set; }
public DateTime DateUpdated { get; set; } public DateTime DateUpdated { get; set; }
public int Width { get; set; } public int Width { get; set; }

75
ErsatzTV.Core/FFmpeg/FFmpegLibraryProcessService.cs

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

5
ErsatzTV.Core/FFmpeg/MediaItemVideoVersion.cs

@ -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
string ffprobePath, string ffprobePath,
bool saveReports, bool saveReports,
Channel channel, Channel channel,
MediaVersion videoVersion, MediaItemVideoVersion videoVersion,
MediaItemAudioVersion audioVersion, MediaItemAudioVersion audioVersion,
string videoPath, string videoPath,
string audioPath, string audioPath,

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

@ -9,4 +9,9 @@ public interface ILocalStatisticsProvider
Task<Either<BaseError, bool>> RefreshStatistics(string ffmpegPath, string ffprobePath, MediaItem mediaItem); Task<Either<BaseError, bool>> RefreshStatistics(string ffmpegPath, string ffprobePath, MediaItem mediaItem);
Either<BaseError, List<SongTag>> GetSongTags(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
Task<Unit> FlagNormal(MediaItem mediaItem); Task<Unit> FlagNormal(MediaItem mediaItem);
Task<Either<BaseError, Unit>> DeleteItems(List<int> mediaItemIds); Task<Either<BaseError, Unit>> DeleteItems(List<int> mediaItemIds);
Task<ImmutableHashSet<string>> GetAllTrashedItems(LibraryPath libraryPath); 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 @@
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
b.Property<int?>("ImageId") b.Property<int?>("ImageId")
.HasColumnType("int"); .HasColumnType("int");
b.Property<double?>("InterlacedRatio")
.HasColumnType("double");
b.Property<int?>("MovieId") b.Property<int?>("MovieId")
.HasColumnType("int"); .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 @@
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
b.Property<int?>("ImageId") b.Property<int?>("ImageId")
.HasColumnType("INTEGER"); .HasColumnType("INTEGER");
b.Property<double?>("InterlacedRatio")
.HasColumnType("REAL");
b.Property<int?>("MovieId") b.Property<int?>("MovieId")
.HasColumnType("INTEGER"); .HasColumnType("INTEGER");

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

@ -112,6 +112,18 @@ public class MediaItemRepository : IMediaItemRepository
.Map(list => list.ToImmutableHashSet()); .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) public async Task<Unit> FlagNormal(MediaItem mediaItem)
{ {
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(); await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();

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

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

124
ErsatzTV.Infrastructure/Metadata/LocalStatisticsProvider.cs

@ -18,7 +18,7 @@ using File = TagLib.File;
namespace ErsatzTV.Infrastructure.Metadata; namespace ErsatzTV.Infrastructure.Metadata;
public class LocalStatisticsProvider : ILocalStatisticsProvider public partial class LocalStatisticsProvider : ILocalStatisticsProvider
{ {
private readonly IClient _client; private readonly IClient _client;
private readonly IHardwareCapabilitiesFactory _hardwareCapabilitiesFactory; private readonly IHardwareCapabilitiesFactory _hardwareCapabilitiesFactory;
@ -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( private async Task<Either<BaseError, bool>> RefreshStatistics(
string ffmpegPath, string ffmpegPath,
string ffprobePath, string ffprobePath,
@ -200,8 +246,7 @@ public class LocalStatisticsProvider : ILocalStatisticsProvider
FFprobe ffprobe = JsonConvert.DeserializeObject<FFprobe>(probe.StandardOutput); FFprobe ffprobe = JsonConvert.DeserializeObject<FFprobe>(probe.StandardOutput);
if (ffprobe is not null) if (ffprobe is not null)
{ {
const string PATTERN = @"\[SAR\s+([0-9]+:[0-9]+)\s+DAR\s+([0-9]+:[0-9]+)\]"; Match match = SarDarRegex().Match(probe.StandardError);
Match match = Regex.Match(probe.StandardError, PATTERN);
if (match.Success) if (match.Success)
{ {
string sar = match.Groups[1].Value; string sar = match.Groups[1].Value;
@ -234,6 +279,55 @@ public class LocalStatisticsProvider : ILocalStatisticsProvider
return BaseError.New("Unable to deserialize ffprobe output"); 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) private async Task AnalyzeDuration(string ffmpegPath, string path, MediaVersion version)
{ {
try try
@ -366,6 +460,7 @@ public class LocalStatisticsProvider : ILocalStatisticsProvider
version.Width = videoStream.width; version.Width = videoStream.width;
version.Height = videoStream.height; version.Height = videoStream.height;
version.VideoScanKind = ScanKindFromFieldOrder(videoStream.field_order); version.VideoScanKind = ScanKindFromFieldOrder(videoStream.field_order);
version.InterlacedRatio = null;
version.RFrameRate = videoStream.r_frame_rate; version.RFrameRate = videoStream.r_frame_rate;
var stream = new MediaStream var stream = new MediaStream
@ -600,4 +695,27 @@ public class LocalStatisticsProvider : ILocalStatisticsProvider
null); null);
} }
// ReSharper restore InconsistentNaming // 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
graphicsElementLoader, graphicsElementLoader,
MemoryCache, MemoryCache,
Substitute.For<IMpegTsScriptService>(), Substitute.For<IMpegTsScriptService>(),
Substitute.For<ILocalStatisticsProvider>(),
Substitute.For<IMediaItemRepository>(),
LoggerFactory.CreateLogger<FFmpegLibraryProcessService>()); LoggerFactory.CreateLogger<FFmpegLibraryProcessService>());
var songVideoGenerator = new SongVideoGenerator(tempFilePool, mockImageCache, service); var songVideoGenerator = new SongVideoGenerator(tempFilePool, mockImageCache, service);
@ -375,7 +377,7 @@ public class TranscodingTests
ExecutableName("ffprobe"), ExecutableName("ffprobe"),
true, true,
channel, channel,
videoVersion, new MediaItemVideoVersion(song, videoVersion),
new MediaItemAudioVersion(song, songVersion), new MediaItemAudioVersion(song, songVersion),
videoPath, videoPath,
file, file,
@ -695,7 +697,7 @@ public class TranscodingTests
ExecutableName("ffprobe"), ExecutableName("ffprobe"),
true, true,
channel, channel,
v, new MediaItemVideoVersion(null, v),
new MediaItemAudioVersion(null, v), new MediaItemAudioVersion(null, v),
file, file,
file, file,
@ -990,6 +992,8 @@ public class TranscodingTests
graphicsElementLoader, graphicsElementLoader,
MemoryCache, MemoryCache,
Substitute.For<IMpegTsScriptService>(), Substitute.For<IMpegTsScriptService>(),
Substitute.For<ILocalStatisticsProvider>(),
Substitute.For<IMediaItemRepository>(),
LoggerFactory.CreateLogger<FFmpegLibraryProcessService>()); LoggerFactory.CreateLogger<FFmpegLibraryProcessService>());
return service; return service;

6
ErsatzTV/Pages/Settings/FFmpegSettings.razor

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

Loading…
Cancel
Save