fix delete old segments (#1536)
* code cleanup * ignore errors deleting old hls segments
This commit is contained in:
@@ -18,6 +18,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
||||
- Fix error loading path replacements when using MySql
|
||||
- Fix tray icon shortcut to open logs folder on Windows
|
||||
- Unlock playout when playout build fails
|
||||
- Ignore errors deleting old HLS segments; this should improve stream reliability
|
||||
|
||||
### Changed
|
||||
- Upgrade from .NET 7 to .NET 8
|
||||
|
||||
@@ -2,5 +2,10 @@ using ErsatzTV.Core.Iptv;
|
||||
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
public record GetChannelPlaylist
|
||||
(string Scheme, string Host, string BaseUrl, string Mode, string UserAgent, string AccessToken) : IRequest<ChannelPlaylist>;
|
||||
public record GetChannelPlaylist(
|
||||
string Scheme,
|
||||
string Host,
|
||||
string BaseUrl,
|
||||
string Mode,
|
||||
string UserAgent,
|
||||
string AccessToken) : IRequest<ChannelPlaylist>;
|
||||
|
||||
+1
-1
@@ -22,7 +22,7 @@ public class
|
||||
_dbContextFactory = dbContextFactory;
|
||||
_hardwareCapabilitiesFactory = hardwareCapabilitiesFactory;
|
||||
}
|
||||
|
||||
|
||||
public async Task<List<HardwareAccelerationKind>> Handle(
|
||||
GetSupportedHardwareAccelerationKinds request,
|
||||
CancellationToken cancellationToken)
|
||||
|
||||
@@ -40,7 +40,9 @@ public class CallJellyfinCollectionScannerHandler : CallLibraryScannerHandler<Sy
|
||||
});
|
||||
}
|
||||
|
||||
protected override async Task<DateTimeOffset> GetLastScan(TvContext dbContext, SynchronizeJellyfinCollections request)
|
||||
protected override async Task<DateTimeOffset> GetLastScan(
|
||||
TvContext dbContext,
|
||||
SynchronizeJellyfinCollections request)
|
||||
{
|
||||
DateTime minDateTime = await dbContext.JellyfinMediaSources
|
||||
.SelectOneAsync(l => l.Id, l => l.Id == request.JellyfinMediaSourceId)
|
||||
|
||||
@@ -2,5 +2,6 @@ using ErsatzTV.Core;
|
||||
|
||||
namespace ErsatzTV.Application.Jellyfin;
|
||||
|
||||
public record SynchronizeJellyfinCollections(int JellyfinMediaSourceId, bool ForceScan) : IRequest<Either<BaseError, Unit>>,
|
||||
public record SynchronizeJellyfinCollections(int JellyfinMediaSourceId, bool ForceScan) :
|
||||
IRequest<Either<BaseError, Unit>>,
|
||||
IScannerBackgroundServiceRequest;
|
||||
|
||||
@@ -36,7 +36,7 @@ public class GetExternalCollectionsHandler : IRequestHandler<GetExternalCollecti
|
||||
|
||||
return embyMediaSourceIds.Map(id => new LibraryViewModel("Emby", 0, "Collections", 0, id, string.Empty));
|
||||
}
|
||||
|
||||
|
||||
private static async Task<IEnumerable<LibraryViewModel>> GetJellyfinExternalCollections(
|
||||
TvContext dbContext,
|
||||
CancellationToken cancellationToken)
|
||||
@@ -49,7 +49,7 @@ public class GetExternalCollectionsHandler : IRequestHandler<GetExternalCollecti
|
||||
return jellyfinMediaSourceIds.Map(
|
||||
id => new LibraryViewModel("Jellyfin", 0, "Collections", 0, id, string.Empty));
|
||||
}
|
||||
|
||||
|
||||
private static async Task<IEnumerable<LibraryViewModel>> GetPlexExternalCollections(
|
||||
TvContext dbContext,
|
||||
CancellationToken cancellationToken)
|
||||
|
||||
@@ -18,15 +18,15 @@ namespace ErsatzTV.Application.Streaming;
|
||||
|
||||
public class StartFFmpegSessionHandler : IRequestHandler<StartFFmpegSession, Either<BaseError, Unit>>
|
||||
{
|
||||
private readonly IClient _client;
|
||||
private readonly IConfigElementRepository _configElementRepository;
|
||||
private readonly IHostApplicationLifetime _hostApplicationLifetime;
|
||||
private readonly IFFmpegSegmenterService _ffmpegSegmenterService;
|
||||
private readonly IHlsPlaylistFilter _hlsPlaylistFilter;
|
||||
private readonly IServiceScopeFactory _serviceScopeFactory;
|
||||
private readonly IMediator _mediator;
|
||||
private readonly IClient _client;
|
||||
private readonly IHostApplicationLifetime _hostApplicationLifetime;
|
||||
private readonly ILocalFileSystem _localFileSystem;
|
||||
private readonly ILogger<StartFFmpegSessionHandler> _logger;
|
||||
private readonly IMediator _mediator;
|
||||
private readonly IServiceScopeFactory _serviceScopeFactory;
|
||||
private readonly ILogger<HlsSessionWorker> _sessionWorkerLogger;
|
||||
private readonly ChannelWriter<IBackgroundServiceRequest> _workerChannel;
|
||||
|
||||
@@ -40,7 +40,7 @@ public class StartFFmpegSessionHandler : IRequestHandler<StartFFmpegSession, Eit
|
||||
ILogger<HlsSessionWorker> sessionWorkerLogger,
|
||||
IFFmpegSegmenterService ffmpegSegmenterService,
|
||||
IConfigElementRepository configElementRepository,
|
||||
IHostApplicationLifetime hostApplicationLifetime,
|
||||
IHostApplicationLifetime hostApplicationLifetime,
|
||||
ChannelWriter<IBackgroundServiceRequest> workerChannel)
|
||||
{
|
||||
_hlsPlaylistFilter = hlsPlaylistFilter;
|
||||
@@ -73,7 +73,7 @@ public class StartFFmpegSessionHandler : IRequestHandler<StartFFmpegSession, Eit
|
||||
Option<int> targetFramerate = await _mediator.Send(
|
||||
new GetChannelFramerate(request.ChannelNumber),
|
||||
cancellationToken);
|
||||
|
||||
|
||||
var worker = new HlsSessionWorker(
|
||||
_serviceScopeFactory,
|
||||
_client,
|
||||
@@ -92,7 +92,7 @@ public class StartFFmpegSessionHandler : IRequestHandler<StartFFmpegSession, Eit
|
||||
_ffmpegSegmenterService.SessionWorkers.TryRemove(
|
||||
request.ChannelNumber,
|
||||
out IHlsSessionWorker inactiveWorker);
|
||||
|
||||
|
||||
inactiveWorker?.Dispose();
|
||||
|
||||
_workerChannel.TryWrite(new ReleaseMemory(false));
|
||||
|
||||
@@ -22,23 +22,23 @@ public class HlsSessionWorker : IHlsSessionWorker
|
||||
{
|
||||
private static readonly SemaphoreSlim Slim = new(1, 1);
|
||||
private static int _workAheadCount;
|
||||
private readonly IMediator _mediator;
|
||||
private readonly IClient _client;
|
||||
private readonly IHlsPlaylistFilter _hlsPlaylistFilter;
|
||||
private readonly IConfigElementRepository _configElementRepository;
|
||||
private readonly IHlsPlaylistFilter _hlsPlaylistFilter;
|
||||
private readonly ILocalFileSystem _localFileSystem;
|
||||
private readonly ILogger<HlsSessionWorker> _logger;
|
||||
private readonly Option<int> _targetFramerate;
|
||||
private readonly IMediator _mediator;
|
||||
private readonly object _sync = new();
|
||||
private readonly Option<int> _targetFramerate;
|
||||
private string _channelNumber;
|
||||
private bool _disposedValue;
|
||||
private bool _hasWrittenSegments;
|
||||
private DateTimeOffset _lastAccess;
|
||||
private DateTimeOffset _lastDelete = DateTimeOffset.MinValue;
|
||||
private IServiceScope _serviceScope;
|
||||
private HlsSessionState _state;
|
||||
private Timer _timer;
|
||||
private DateTimeOffset _transcodedUntil;
|
||||
private IServiceScope _serviceScope;
|
||||
|
||||
public HlsSessionWorker(
|
||||
IServiceScopeFactory serviceScopeFactory,
|
||||
@@ -223,7 +223,7 @@ public class HlsSessionWorker : IHlsSessionWorker
|
||||
{
|
||||
_timer.Dispose();
|
||||
_timer = null;
|
||||
|
||||
|
||||
_serviceScope.Dispose();
|
||||
_serviceScope = null;
|
||||
}
|
||||
@@ -278,14 +278,14 @@ public class HlsSessionWorker : IHlsSessionWorker
|
||||
{
|
||||
Interlocked.Increment(ref _workAheadCount);
|
||||
_logger.LogInformation("HLS segmenter will work ahead for channel {Channel}", _channelNumber);
|
||||
|
||||
|
||||
HlsSessionState nextState = _state switch
|
||||
{
|
||||
HlsSessionState.SeekAndRealtime => HlsSessionState.SeekAndWorkAhead,
|
||||
HlsSessionState.ZeroAndRealtime => HlsSessionState.ZeroAndWorkAhead,
|
||||
_ => _state
|
||||
};
|
||||
|
||||
|
||||
if (nextState != _state)
|
||||
{
|
||||
_logger.LogDebug("HLS session state accelerating {Last} => {Next}", _state, nextState);
|
||||
@@ -512,7 +512,16 @@ public class HlsSessionWorker : IHlsSessionWorker
|
||||
|
||||
foreach (Segment segment in toDelete)
|
||||
{
|
||||
File.Delete(segment.File);
|
||||
try
|
||||
{
|
||||
File.Delete(segment.File);
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
// work around lots of:
|
||||
// The process cannot access the file '...' because it is being used by another process
|
||||
_logger.LogDebug("Failed to delete old segment {File}", segment.File);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -551,11 +560,9 @@ public class HlsSessionWorker : IHlsSessionWorker
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<int> GetWorkAheadLimit()
|
||||
{
|
||||
return await _configElementRepository.GetValue<int>(ConfigElementKey.FFmpegWorkAheadSegmenters)
|
||||
private async Task<int> GetWorkAheadLimit() =>
|
||||
await _configElementRepository.GetValue<int>(ConfigElementKey.FFmpegWorkAheadSegmenters)
|
||||
.Map(maybeCount => maybeCount.Match(identity, () => 1));
|
||||
}
|
||||
|
||||
private async Task<Option<string[]>> ReadPlaylistLines(CancellationToken cancellationToken)
|
||||
{
|
||||
|
||||
@@ -17,7 +17,7 @@ public class PlayoutModeSchedulerDurationTests : SchedulerTestBase
|
||||
|
||||
private CancellationToken _cancellationToken;
|
||||
private readonly ILogger<PlayoutModeSchedulerDuration> _logger;
|
||||
|
||||
|
||||
public PlayoutModeSchedulerDurationTests()
|
||||
{
|
||||
Log.Logger = new LoggerConfiguration()
|
||||
@@ -725,7 +725,7 @@ public class PlayoutModeSchedulerDurationTests : SchedulerTestBase
|
||||
playoutItems[6].FillerKind.Should().Be(FillerKind.Fallback);
|
||||
playoutItems[6].GuideFinish.HasValue.Should().BeFalse();
|
||||
}
|
||||
|
||||
|
||||
[Test]
|
||||
public void Should_Not_Have_Gap_With_Post_Roll_Pad_And_Fallback_Filler()
|
||||
{
|
||||
@@ -742,7 +742,7 @@ public class PlayoutModeSchedulerDurationTests : SchedulerTestBase
|
||||
{ 10, TimeSpan.Parse("00:00:31.5791160") },
|
||||
{ 11, TimeSpan.Parse("00:00:31.2540360") },
|
||||
{ 12, TimeSpan.Parse("00:00:36.2231070") },
|
||||
{ 13, TimeSpan.Parse("00:02:00.0471430") },
|
||||
{ 13, TimeSpan.Parse("00:02:00.0471430") }
|
||||
});
|
||||
Collection collectionThree = TwoItemCollection(14, 15, TimeSpan.Parse("00:00:55.6349890"));
|
||||
|
||||
@@ -820,7 +820,7 @@ public class PlayoutModeSchedulerDurationTests : SchedulerTestBase
|
||||
|
||||
playoutBuilderState.CurrentTime.Should().Be(startState.CurrentTime.AddMinutes(30));
|
||||
playoutItems.Last().FinishOffset.Should().Be(playoutBuilderState.CurrentTime);
|
||||
|
||||
|
||||
// THIS IS THE KEY TEST - needs to be exactly 30 minutes
|
||||
(playoutItems.Last().FinishOffset - playoutItems.First().StartOffset).Should().Be(TimeSpan.FromMinutes(30));
|
||||
|
||||
|
||||
@@ -45,7 +45,7 @@ public abstract class SchedulerTestBase
|
||||
{ CollectionKey.ForFillerPreset(fillerPreset), enumerator2 },
|
||||
{ CollectionKey.ForFillerPreset(fillerPreset2), enumerator3 }
|
||||
};
|
||||
|
||||
|
||||
protected static Dictionary<CollectionKey, IMediaCollectionEnumerator> CollectionEnumerators(
|
||||
ProgramScheduleItem scheduleItem,
|
||||
IMediaCollectionEnumerator enumerator1,
|
||||
@@ -98,7 +98,7 @@ public abstract class SchedulerTestBase
|
||||
TestMovie(id2, duration, new DateTime(2020, 1, 2), chapterCount)
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
protected static Collection CollectionOf(IDictionary<int, TimeSpan> idsAndDurations, int chapterCount = 0)
|
||||
{
|
||||
var mediaItems = new List<MediaItem>();
|
||||
|
||||
@@ -10,9 +10,9 @@ public class PlayoutScheduleItemFillGroupIndex
|
||||
|
||||
[NotLogged]
|
||||
public Playout Playout { get; set; }
|
||||
|
||||
|
||||
public int ProgramScheduleItemId { get; set; }
|
||||
|
||||
|
||||
[NotLogged]
|
||||
public ProgramScheduleItem ProgramScheduleItem { get; set; }
|
||||
|
||||
|
||||
@@ -13,8 +13,10 @@ public abstract class ProgramScheduleItem
|
||||
public GuideMode GuideMode { get; set; }
|
||||
public string CustomTitle { get; set; }
|
||||
public int ProgramScheduleId { get; set; }
|
||||
|
||||
[JsonIgnore]
|
||||
public ProgramSchedule ProgramSchedule { get; set; }
|
||||
|
||||
public int? CollectionId { get; set; }
|
||||
public Collection Collection { get; set; }
|
||||
public int? MediaItemId { get; set; }
|
||||
|
||||
@@ -329,7 +329,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
|
||||
channel.FFmpegProfile.Resolution.Width,
|
||||
channel.FFmpegProfile.Resolution.Height);
|
||||
}
|
||||
|
||||
|
||||
var desiredState = new FrameState(
|
||||
playbackSettings.RealtimeOutput,
|
||||
fillerKind == FillerKind.Fallback,
|
||||
|
||||
@@ -67,7 +67,7 @@ public interface IPlexServerApiClient
|
||||
PlexLibrary library,
|
||||
PlexConnection connection,
|
||||
PlexServerAuthToken token);
|
||||
|
||||
|
||||
IAsyncEnumerable<PlexCollection> GetAllCollections(
|
||||
PlexConnection connection,
|
||||
PlexServerAuthToken token,
|
||||
|
||||
@@ -9,9 +9,9 @@ public class ChannelPlaylist
|
||||
private readonly string _accessToken;
|
||||
private readonly string _baseUrl;
|
||||
private readonly List<Channel> _channels;
|
||||
private readonly string _userAgent;
|
||||
private readonly string _host;
|
||||
private readonly string _scheme;
|
||||
private readonly string _userAgent;
|
||||
|
||||
public ChannelPlaylist(
|
||||
string scheme,
|
||||
@@ -58,7 +58,7 @@ public class ChannelPlaylist
|
||||
|
||||
sb.AppendLine("#KODIPROP:inputstream.ffmpegdirect.open_mode=ffmpeg");
|
||||
}
|
||||
|
||||
|
||||
string logo = Optional(channel.Artwork).Flatten()
|
||||
.Filter(a => a.ArtworkKind == ArtworkKind.Logo)
|
||||
.HeadOrNone()
|
||||
|
||||
@@ -424,11 +424,12 @@ public class PlayoutBuilder : IPlayoutBuilder
|
||||
cancellationToken);
|
||||
collectionEnumerators.Add(collectionKey, enumerator);
|
||||
}
|
||||
|
||||
|
||||
var collectionItemCount = collectionMediaItems.Map((k, v) => (k, v.Count)).Values.ToDictionary();
|
||||
|
||||
|
||||
var scheduleItemsFillGroupEnumerators = new Dictionary<int, IScheduleItemsEnumerator>();
|
||||
foreach (ProgramScheduleItem scheduleItem in sortedScheduleItems.Where(si => si.FillWithGroupMode is not FillWithGroupMode.None))
|
||||
foreach (ProgramScheduleItem scheduleItem in sortedScheduleItems.Where(
|
||||
si => si.FillWithGroupMode is not FillWithGroupMode.None))
|
||||
{
|
||||
var collectionKey = CollectionKey.ForScheduleItem(scheduleItem);
|
||||
List<MediaItem> mediaItems = await MediaItemsForCollection.Collect(
|
||||
@@ -439,14 +440,15 @@ public class PlayoutBuilder : IPlayoutBuilder
|
||||
var fakeCollections = _mediaCollectionRepository.GroupIntoFakeCollections(mediaItems)
|
||||
.Filter(c => c.ShowId > 0 || c.ArtistId > 0)
|
||||
.ToList();
|
||||
List<ProgramScheduleItem> fakeScheduleItems = [];
|
||||
|
||||
List<ProgramScheduleItem> fakeScheduleItems = []
|
||||
;
|
||||
|
||||
// this will be used to clone a schedule item
|
||||
MethodInfo generic = typeof(JsonConvert).GetMethods()
|
||||
.FirstOrDefault(
|
||||
x => x.Name.Equals("DeserializeObject", StringComparison.OrdinalIgnoreCase) && x.IsGenericMethod &&
|
||||
x.GetParameters().Length == 1)?.MakeGenericMethod(scheduleItem.GetType());
|
||||
|
||||
|
||||
foreach (CollectionWithItems fakeCollection in fakeCollections)
|
||||
{
|
||||
var key = new CollectionKey
|
||||
@@ -473,7 +475,7 @@ public class PlayoutBuilder : IPlayoutBuilder
|
||||
cancellationToken);
|
||||
|
||||
collectionEnumerators.Add(key, enumerator);
|
||||
|
||||
|
||||
// this makes multiple (0) work - since it needs the number of items in the collection
|
||||
collectionItemCount.Add(key, fakeCollection.MediaItems.Count);
|
||||
}
|
||||
@@ -572,7 +574,7 @@ public class PlayoutBuilder : IPlayoutBuilder
|
||||
|
||||
// get the schedule item out of the sorted list
|
||||
ProgramScheduleItem scheduleItem = playoutBuilderState.ScheduleItemsEnumerator.Current;
|
||||
|
||||
|
||||
// replace with the fake schedule item when filling with group
|
||||
if (scheduleItem.FillWithGroupMode is not FillWithGroupMode.None)
|
||||
{
|
||||
@@ -672,7 +674,7 @@ public class PlayoutBuilder : IPlayoutBuilder
|
||||
activeSchedule,
|
||||
collectionEnumerators,
|
||||
saveAnchorDate);
|
||||
|
||||
|
||||
// build fill group indices
|
||||
playout.FillGroupIndices = BuildFillGroupIndices(playout, scheduleItemsFillGroupEnumerators);
|
||||
|
||||
@@ -684,7 +686,7 @@ public class PlayoutBuilder : IPlayoutBuilder
|
||||
Dictionary<int, IScheduleItemsEnumerator> scheduleItemsFillGroupEnumerators)
|
||||
{
|
||||
var result = playout.FillGroupIndices.ToList();
|
||||
|
||||
|
||||
foreach ((int programScheduleItemId, IScheduleItemsEnumerator enumerator) in scheduleItemsFillGroupEnumerators)
|
||||
{
|
||||
Option<PlayoutScheduleItemFillGroupIndex> maybeFgi = Optional(
|
||||
@@ -694,7 +696,7 @@ public class PlayoutBuilder : IPlayoutBuilder
|
||||
{
|
||||
fgi.EnumeratorState = enumerator.State;
|
||||
}
|
||||
|
||||
|
||||
if (maybeFgi.IsNone)
|
||||
{
|
||||
var fgi = new PlayoutScheduleItemFillGroupIndex
|
||||
@@ -703,7 +705,7 @@ public class PlayoutBuilder : IPlayoutBuilder
|
||||
ProgramScheduleItemId = programScheduleItemId,
|
||||
EnumeratorState = enumerator.State
|
||||
};
|
||||
|
||||
|
||||
result.Add(fgi);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -223,7 +223,7 @@ public abstract class PlayoutModeSchedulerBase<T> : IPlayoutModeScheduler<T> whe
|
||||
Logger.LogError("Multiple pad-to-nearest-minute values are invalid; no filler will be used");
|
||||
return new List<PlayoutItem> { playoutItem };
|
||||
}
|
||||
|
||||
|
||||
// missing pad-to-nearest-minute value is invalid; use no filler
|
||||
FillerPreset invalidPadFiller = allFiller
|
||||
.FirstOrDefault(f => f.FillerMode == FillerMode.Pad && f.PadToNearestMinute.HasValue == false);
|
||||
|
||||
@@ -11,10 +11,8 @@ public class PlayoutModeSchedulerMultiple : PlayoutModeSchedulerBase<ProgramSche
|
||||
private readonly Map<CollectionKey, int> _collectionItemCount;
|
||||
|
||||
public PlayoutModeSchedulerMultiple(Map<CollectionKey, int> collectionItemCount, ILogger logger)
|
||||
: base(logger)
|
||||
{
|
||||
: base(logger) =>
|
||||
_collectionItemCount = collectionItemCount;
|
||||
}
|
||||
|
||||
public override Tuple<PlayoutBuilderState, List<PlayoutItem>> Schedule(
|
||||
PlayoutBuilderState playoutBuilderState,
|
||||
|
||||
@@ -15,7 +15,8 @@ public class RandomizedMediaCollectionEnumerator : IMediaCollectionEnumerator
|
||||
{
|
||||
_mediaItems = mediaItems;
|
||||
_lazyMinimumDuration =
|
||||
new Lazy<Option<TimeSpan>>(() => _mediaItems.Bind(i => i.GetNonZeroDuration()).OrderBy(identity).HeadOrNone());
|
||||
new Lazy<Option<TimeSpan>>(
|
||||
() => _mediaItems.Bind(i => i.GetNonZeroDuration()).OrderBy(identity).HeadOrNone());
|
||||
_random = new Random(state.Seed);
|
||||
|
||||
State = new CollectionEnumeratorState { Seed = state.Seed };
|
||||
|
||||
@@ -34,7 +34,8 @@ public class ShuffleInOrderCollectionEnumerator : IMediaCollectionEnumerator
|
||||
_random = new Random(state.Seed);
|
||||
_shuffled = Shuffle(_collections, _random);
|
||||
_lazyMinimumDuration =
|
||||
new Lazy<Option<TimeSpan>>(() => _shuffled.Bind(i => i.GetNonZeroDuration()).OrderBy(identity).HeadOrNone());
|
||||
new Lazy<Option<TimeSpan>>(
|
||||
() => _shuffled.Bind(i => i.GetNonZeroDuration()).OrderBy(identity).HeadOrNone());
|
||||
|
||||
State = new CollectionEnumeratorState { Seed = state.Seed };
|
||||
while (State.Index < state.Index)
|
||||
|
||||
@@ -31,7 +31,8 @@ public class ShuffledMediaCollectionEnumerator : IMediaCollectionEnumerator
|
||||
_random = new CloneableRandom(state.Seed);
|
||||
_shuffled = Shuffle(_mediaItems, _random);
|
||||
_lazyMinimumDuration =
|
||||
new Lazy<Option<TimeSpan>>(() => _shuffled.Bind(i => i.GetNonZeroDuration()).OrderBy(identity).HeadOrNone());
|
||||
new Lazy<Option<TimeSpan>>(
|
||||
() => _shuffled.Bind(i => i.GetNonZeroDuration()).OrderBy(identity).HeadOrNone());
|
||||
|
||||
State = new CollectionEnumeratorState { Seed = state.Seed };
|
||||
while (State.Index < state.Index)
|
||||
|
||||
@@ -5,10 +5,10 @@ namespace ErsatzTV.FFmpeg.Capabilities;
|
||||
|
||||
public class FFmpegCapabilities : IFFmpegCapabilities
|
||||
{
|
||||
private readonly IReadOnlySet<string> _ffmpegHardwareAccelerations;
|
||||
private readonly IReadOnlySet<string> _ffmpegDecoders;
|
||||
private readonly IReadOnlySet<string> _ffmpegEncoders;
|
||||
private readonly IReadOnlySet<string> _ffmpegFilters;
|
||||
private readonly IReadOnlySet<string> _ffmpegHardwareAccelerations;
|
||||
private readonly IReadOnlySet<string> _ffmpegOptions;
|
||||
|
||||
public FFmpegCapabilities(
|
||||
@@ -32,8 +32,8 @@ public class FFmpegCapabilities : IFFmpegCapabilities
|
||||
{
|
||||
return _ffmpegEncoders.Any(
|
||||
e => e.EndsWith($"_{FFmpegKnownHardwareAcceleration.Amf.Name}", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Option<FFmpegKnownHardwareAcceleration> maybeAccelToCheck = hardwareAccelerationMode switch
|
||||
{
|
||||
HardwareAccelerationMode.Nvenc => FFmpegKnownHardwareAcceleration.Cuda,
|
||||
|
||||
@@ -2,13 +2,6 @@ namespace ErsatzTV.FFmpeg.Capabilities;
|
||||
|
||||
public record FFmpegKnownDecoder
|
||||
{
|
||||
public string Name { get; }
|
||||
|
||||
private FFmpegKnownDecoder(string Name)
|
||||
{
|
||||
this.Name = Name;
|
||||
}
|
||||
|
||||
public static readonly FFmpegKnownDecoder Av1Cuvid = new("av1_cuvid");
|
||||
public static readonly FFmpegKnownDecoder H264Cuvid = new("h264_cuvid");
|
||||
public static readonly FFmpegKnownDecoder HevcCuvid = new("hevc_cuvid");
|
||||
@@ -17,6 +10,10 @@ public record FFmpegKnownDecoder
|
||||
public static readonly FFmpegKnownDecoder Vc1Cuvid = new("vc1_cuvid");
|
||||
public static readonly FFmpegKnownDecoder Vp9Cuvid = new("vp9_cuvid");
|
||||
|
||||
private FFmpegKnownDecoder(string Name) => this.Name = Name;
|
||||
|
||||
public string Name { get; }
|
||||
|
||||
public static IList<string> AllDecoders =>
|
||||
new[]
|
||||
{
|
||||
|
||||
@@ -2,12 +2,9 @@ namespace ErsatzTV.FFmpeg.Capabilities;
|
||||
|
||||
public record FFmpegKnownEncoder
|
||||
{
|
||||
public string Name { get; }
|
||||
private FFmpegKnownEncoder(string Name) => this.Name = Name;
|
||||
|
||||
private FFmpegKnownEncoder(string Name)
|
||||
{
|
||||
this.Name = Name;
|
||||
}
|
||||
public string Name { get; }
|
||||
|
||||
// only list the encoders that we actually check for
|
||||
public static IList<string> AllEncoders =>
|
||||
|
||||
@@ -2,15 +2,12 @@ namespace ErsatzTV.FFmpeg.Capabilities;
|
||||
|
||||
public record FFmpegKnownFilter
|
||||
{
|
||||
public string Name { get; }
|
||||
|
||||
private FFmpegKnownFilter(string Name)
|
||||
{
|
||||
this.Name = Name;
|
||||
}
|
||||
|
||||
public static readonly FFmpegKnownFilter ScaleNpp = new("scale_npp");
|
||||
|
||||
private FFmpegKnownFilter(string Name) => this.Name = Name;
|
||||
|
||||
public string Name { get; }
|
||||
|
||||
public static IList<string> AllFilters =>
|
||||
new[]
|
||||
{
|
||||
|
||||
@@ -2,19 +2,16 @@ namespace ErsatzTV.FFmpeg.Capabilities;
|
||||
|
||||
public record FFmpegKnownHardwareAcceleration
|
||||
{
|
||||
public string Name { get; }
|
||||
|
||||
private FFmpegKnownHardwareAcceleration(string Name)
|
||||
{
|
||||
this.Name = Name;
|
||||
}
|
||||
|
||||
public static readonly FFmpegKnownHardwareAcceleration Amf = new("amf");
|
||||
public static readonly FFmpegKnownHardwareAcceleration Cuda = new("cuda");
|
||||
public static readonly FFmpegKnownHardwareAcceleration Qsv = new("qsv");
|
||||
public static readonly FFmpegKnownHardwareAcceleration Vaapi = new("vaapi");
|
||||
public static readonly FFmpegKnownHardwareAcceleration VideoToolbox = new("videotoolbox");
|
||||
|
||||
private FFmpegKnownHardwareAcceleration(string Name) => this.Name = Name;
|
||||
|
||||
public string Name { get; }
|
||||
|
||||
public static IList<string> AllAccels =>
|
||||
new[]
|
||||
{
|
||||
|
||||
@@ -6,15 +6,12 @@ namespace ErsatzTV.FFmpeg.Capabilities;
|
||||
[SuppressMessage("ReSharper", "StringLiteralTypo")]
|
||||
public record FFmpegKnownOption
|
||||
{
|
||||
public string Name { get; }
|
||||
|
||||
private FFmpegKnownOption(string Name)
|
||||
{
|
||||
this.Name = Name;
|
||||
}
|
||||
|
||||
public static readonly FFmpegKnownOption ReadrateInitialBurst = new("readrate_initial_burst");
|
||||
|
||||
private FFmpegKnownOption(string Name) => this.Name = Name;
|
||||
|
||||
public string Name { get; }
|
||||
|
||||
public static IList<string> AllOptions =>
|
||||
new[]
|
||||
{
|
||||
|
||||
@@ -18,7 +18,10 @@ public class HardwareCapabilitiesFactory : IHardwareCapabilitiesFactory
|
||||
{
|
||||
private const string ArchitectureCacheKey = "ffmpeg.hardware.nvidia.architecture";
|
||||
private const string ModelCacheKey = "ffmpeg.hardware.nvidia.model";
|
||||
private static readonly CompositeFormat VaapiCacheKeyFormat = CompositeFormat.Parse("ffmpeg.hardware.vaapi.{0}.{1}");
|
||||
|
||||
private static readonly CompositeFormat
|
||||
VaapiCacheKeyFormat = CompositeFormat.Parse("ffmpeg.hardware.vaapi.{0}.{1}");
|
||||
|
||||
private static readonly CompositeFormat QsvCacheKeyFormat = CompositeFormat.Parse("ffmpeg.hardware.qsv.{0}");
|
||||
private static readonly CompositeFormat FFmpegCapabilitiesCacheKeyFormat = CompositeFormat.Parse("ffmpeg.{0}");
|
||||
|
||||
@@ -86,7 +89,7 @@ public class HardwareCapabilitiesFactory : IHardwareCapabilitiesFactory
|
||||
{
|
||||
return new NoHardwareCapabilities();
|
||||
}
|
||||
|
||||
|
||||
if (!ffmpegCapabilities.HasHardwareAcceleration(hardwareAccelerationMode))
|
||||
{
|
||||
_logger.LogWarning(
|
||||
@@ -95,7 +98,7 @@ public class HardwareCapabilitiesFactory : IHardwareCapabilitiesFactory
|
||||
|
||||
return new NoHardwareCapabilities();
|
||||
}
|
||||
|
||||
|
||||
return hardwareAccelerationMode switch
|
||||
{
|
||||
HardwareAccelerationMode.Nvenc => await GetNvidiaCapabilities(ffmpegPath, ffmpegCapabilities),
|
||||
@@ -135,7 +138,7 @@ public class HardwareCapabilitiesFactory : IHardwareCapabilitiesFactory
|
||||
var arguments = option.GlobalOptions.ToList();
|
||||
|
||||
arguments.AddRange(QsvArguments);
|
||||
|
||||
|
||||
BufferedCommandResult result = await Cli.Wrap(ffmpegPath)
|
||||
.WithArguments(arguments)
|
||||
.WithValidation(CommandResultValidation.None)
|
||||
@@ -360,7 +363,7 @@ public class HardwareCapabilitiesFactory : IHardwareCapabilitiesFactory
|
||||
"Detected {Count} VAAPI profile entrypoints for using QSV device {Device}",
|
||||
profileEntrypoints.Count,
|
||||
device);
|
||||
|
||||
|
||||
_memoryCache.Set(cacheKey, profileEntrypoints);
|
||||
return new VaapiHardwareCapabilities(profileEntrypoints, _logger);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ public class DecoderImplicitCuda : DecoderBase
|
||||
{
|
||||
protected override FrameDataLocation OutputFrameDataLocation => FrameDataLocation.Hardware;
|
||||
public override string Name => string.Empty;
|
||||
|
||||
public override string[] InputOptions(InputFile inputFile) =>
|
||||
new[]
|
||||
{
|
||||
|
||||
@@ -4,8 +4,8 @@ namespace ErsatzTV.FFmpeg.Filter;
|
||||
|
||||
public class CropFilter : BaseFilter
|
||||
{
|
||||
private readonly FrameState _currentState;
|
||||
private readonly FrameSize _croppedSize;
|
||||
private readonly FrameState _currentState;
|
||||
|
||||
public CropFilter(FrameState currentState, FrameSize croppedSize)
|
||||
{
|
||||
|
||||
@@ -4,10 +4,10 @@ namespace ErsatzTV.FFmpeg.Filter.Cuda;
|
||||
|
||||
public class ScaleCudaFilter : BaseFilter
|
||||
{
|
||||
private readonly Option<FrameSize> _croppedSize;
|
||||
private readonly FrameState _currentState;
|
||||
private readonly bool _isAnamorphicEdgeCase;
|
||||
private readonly FrameSize _paddedSize;
|
||||
private readonly Option<FrameSize> _croppedSize;
|
||||
private readonly FrameSize _scaledSize;
|
||||
|
||||
public ScaleCudaFilter(
|
||||
|
||||
@@ -4,10 +4,7 @@ public class NormalizeLoudnessFilter : BaseFilter
|
||||
{
|
||||
private readonly AudioFilter _loudnessFilter;
|
||||
|
||||
public NormalizeLoudnessFilter(AudioFilter loudnessFilter)
|
||||
{
|
||||
_loudnessFilter = loudnessFilter;
|
||||
}
|
||||
public NormalizeLoudnessFilter(AudioFilter loudnessFilter) => _loudnessFilter = loudnessFilter;
|
||||
|
||||
public override string Filter => _loudnessFilter switch
|
||||
{
|
||||
|
||||
@@ -4,10 +4,10 @@ namespace ErsatzTV.FFmpeg.Filter;
|
||||
|
||||
public class ScaleFilter : BaseFilter
|
||||
{
|
||||
private readonly Option<FrameSize> _croppedSize;
|
||||
private readonly FrameState _currentState;
|
||||
private readonly bool _isAnamorphicEdgeCase;
|
||||
private readonly FrameSize _paddedSize;
|
||||
private readonly Option<FrameSize> _croppedSize;
|
||||
private readonly FrameSize _scaledSize;
|
||||
|
||||
public ScaleFilter(
|
||||
|
||||
@@ -4,10 +4,10 @@ namespace ErsatzTV.FFmpeg.Filter.Vaapi;
|
||||
|
||||
public class ScaleVaapiFilter : BaseFilter
|
||||
{
|
||||
private readonly Option<FrameSize> _croppedSize;
|
||||
private readonly FrameState _currentState;
|
||||
private readonly bool _isAnamorphicEdgeCase;
|
||||
private readonly FrameSize _paddedSize;
|
||||
private readonly Option<FrameSize> _croppedSize;
|
||||
private readonly FrameSize _scaledSize;
|
||||
|
||||
public ScaleVaapiFilter(
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
public class VaapiSubtitlePixelFormatFilter : BaseFilter
|
||||
{
|
||||
public override FrameState NextState(FrameState currentState) => currentState;
|
||||
|
||||
public override string Filter => "format=vaapi|yuva420p|yuva444p|yuva422p|rgba|abgr|bgra|gbrap|ya8";
|
||||
public override FrameState NextState(FrameState currentState) => currentState;
|
||||
}
|
||||
|
||||
@@ -16,9 +16,8 @@ public class VideoFilter : IPipelineStep
|
||||
public FrameState NextState(FrameState currentState) => currentState;
|
||||
|
||||
private string[] Arguments() =>
|
||||
new []
|
||||
{
|
||||
"-vf",
|
||||
[
|
||||
"-vf",
|
||||
string.Join(",", _filterSteps.Map(fs => fs.Filter))
|
||||
};
|
||||
];
|
||||
}
|
||||
|
||||
@@ -120,7 +120,7 @@ public record VideoStream(
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
public FrameSize SquarePixelFrameSizeForCrop(FrameSize resolution)
|
||||
{
|
||||
int width = FrameSize.Width;
|
||||
|
||||
@@ -6,8 +6,8 @@ public class OutputFormatHls : IPipelineStep
|
||||
{
|
||||
private readonly FrameState _desiredState;
|
||||
private readonly Option<string> _mediaFrameRate;
|
||||
private readonly string _playlistPath;
|
||||
private readonly bool _oneSecondGop;
|
||||
private readonly string _playlistPath;
|
||||
private readonly string _segmentTemplate;
|
||||
|
||||
public OutputFormatHls(
|
||||
@@ -37,7 +37,7 @@ public class OutputFormatHls : IPipelineStep
|
||||
int frameRate = _desiredState.FrameRate.IfNone(GetFrameRateFromMedia);
|
||||
|
||||
int gop = _oneSecondGop ? frameRate : frameRate * SEGMENT_SECONDS;
|
||||
|
||||
|
||||
return new[]
|
||||
{
|
||||
"-g", $"{gop}",
|
||||
|
||||
@@ -475,7 +475,8 @@ public class NvidiaPipelineBuilder : SoftwarePipelineBuilder
|
||||
subtitle.FilterSteps.Add(subtitleHardwareUpload);
|
||||
|
||||
// only scale if scaling or padding was used for main video stream
|
||||
if (videoInputFile.FilterSteps.Any(s => s is ScaleFilter or ScaleCudaFilter { IsFormatOnly: false } or PadFilter))
|
||||
if (videoInputFile.FilterSteps.Any(
|
||||
s => s is ScaleFilter or ScaleCudaFilter { IsFormatOnly: false } or PadFilter))
|
||||
{
|
||||
var scaleFilter = new SubtitleScaleNppFilter(desiredState.PaddedSize);
|
||||
subtitle.FilterSteps.Add(scaleFilter);
|
||||
@@ -484,17 +485,18 @@ public class NvidiaPipelineBuilder : SoftwarePipelineBuilder
|
||||
else
|
||||
{
|
||||
// only scale if scaling or padding was used for main video stream
|
||||
if (videoInputFile.FilterSteps.Any(s => s is ScaleFilter or ScaleCudaFilter { IsFormatOnly: false } or PadFilter))
|
||||
if (videoInputFile.FilterSteps.Any(
|
||||
s => s is ScaleFilter or ScaleCudaFilter { IsFormatOnly: false } or PadFilter))
|
||||
{
|
||||
var scaleFilter = new ScaleImageFilter(desiredState.PaddedSize);
|
||||
subtitle.FilterSteps.Add(scaleFilter);
|
||||
}
|
||||
|
||||
|
||||
var subtitleHardwareUpload = new HardwareUploadCudaFilter(
|
||||
currentState with { FrameDataLocation = FrameDataLocation.Software });
|
||||
subtitle.FilterSteps.Add(subtitleHardwareUpload);
|
||||
}
|
||||
|
||||
|
||||
var subtitlesFilter = new OverlaySubtitleCudaFilter();
|
||||
subtitleOverlayFilterSteps.Add(subtitlesFilter);
|
||||
}
|
||||
|
||||
@@ -510,7 +510,7 @@ public abstract class PipelineBuilderBase : IPipelineBuilder
|
||||
FrameState desiredState,
|
||||
string fontsFolder,
|
||||
ICollection<IPipelineStep> pipelineSteps);
|
||||
|
||||
|
||||
protected static FrameState SetCrop(
|
||||
VideoInputFile videoInputFile,
|
||||
FrameState desiredState,
|
||||
@@ -631,7 +631,7 @@ public abstract class PipelineBuilderBase : IPipelineBuilder
|
||||
AudioFilter filter = _audioInputFile
|
||||
.Map(a => a.DesiredState.NormalizeLoudnessFilter)
|
||||
.IfNone(AudioFilter.None);
|
||||
|
||||
|
||||
initialBurst = filter switch
|
||||
{
|
||||
AudioFilter.LoudNorm => 5,
|
||||
|
||||
@@ -72,17 +72,18 @@ public class PipelineBuilderFactory : IPipelineBuilderFactory
|
||||
reportsFolder,
|
||||
fontsFolder,
|
||||
_logger),
|
||||
HardwareAccelerationMode.VideoToolbox when capabilities is not NoHardwareCapabilities => new VideoToolboxPipelineBuilder(
|
||||
ffmpegCapabilities,
|
||||
capabilities,
|
||||
hardwareAccelerationMode,
|
||||
videoInputFile,
|
||||
audioInputFile,
|
||||
watermarkInputFile,
|
||||
subtitleInputFile,
|
||||
reportsFolder,
|
||||
fontsFolder,
|
||||
_logger),
|
||||
HardwareAccelerationMode.VideoToolbox when capabilities is not NoHardwareCapabilities => new
|
||||
VideoToolboxPipelineBuilder(
|
||||
ffmpegCapabilities,
|
||||
capabilities,
|
||||
hardwareAccelerationMode,
|
||||
videoInputFile,
|
||||
audioInputFile,
|
||||
watermarkInputFile,
|
||||
subtitleInputFile,
|
||||
reportsFolder,
|
||||
fontsFolder,
|
||||
_logger),
|
||||
HardwareAccelerationMode.Amf when capabilities is not NoHardwareCapabilities => new AmfPipelineBuilder(
|
||||
ffmpegCapabilities,
|
||||
capabilities,
|
||||
|
||||
@@ -87,7 +87,7 @@ public class VaapiPipelineBuilder : SoftwarePipelineBuilder
|
||||
{
|
||||
pipelineSteps.Add(new NoAutoScaleOutputOption());
|
||||
}
|
||||
|
||||
|
||||
// disable hw accel if decoder/encoder isn't supported
|
||||
return ffmpegState with
|
||||
{
|
||||
|
||||
+3
-2
@@ -4,12 +4,13 @@ using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace ErsatzTV.Infrastructure.Data.Configurations;
|
||||
|
||||
public class PlayoutScheduleItemFillGroupIndexConfiguration : IEntityTypeConfiguration<PlayoutScheduleItemFillGroupIndex>
|
||||
public class
|
||||
PlayoutScheduleItemFillGroupIndexConfiguration : IEntityTypeConfiguration<PlayoutScheduleItemFillGroupIndex>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<PlayoutScheduleItemFillGroupIndex> builder)
|
||||
{
|
||||
builder.ToTable("PlayoutScheduleItemFillGroupIndex");
|
||||
|
||||
|
||||
builder.OwnsOne(a => a.EnumeratorState).ToTable("FillGroupEnumeratorState");
|
||||
|
||||
builder.HasOne(i => i.ProgramScheduleItem)
|
||||
|
||||
@@ -210,7 +210,7 @@ public class EmbyTelevisionRepository : IEmbyTelevisionRepository
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
|
||||
|
||||
episode.State = MediaItemState.Normal;
|
||||
|
||||
|
||||
Option<int> maybeId = await dbContext.Connection.ExecuteScalarAsync<int>(
|
||||
@"SELECT EmbyEpisode.Id FROM EmbyEpisode
|
||||
INNER JOIN MediaItem MI ON MI.Id = EmbyEpisode.Id
|
||||
@@ -256,7 +256,7 @@ public class EmbyTelevisionRepository : IEmbyTelevisionRepository
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
|
||||
|
||||
show.State = MediaItemState.Normal;
|
||||
|
||||
|
||||
Option<int> maybeId = await dbContext.Connection.ExecuteScalarAsync<int>(
|
||||
@"SELECT EmbyShow.Id FROM EmbyShow
|
||||
INNER JOIN MediaItem MI ON MI.Id = EmbyShow.Id
|
||||
|
||||
@@ -214,7 +214,7 @@ public class JellyfinTelevisionRepository : IJellyfinTelevisionRepository
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
|
||||
|
||||
episode.State = MediaItemState.Normal;
|
||||
|
||||
|
||||
Option<int> maybeId = await dbContext.Connection.ExecuteScalarAsync<int>(
|
||||
@"SELECT JellyfinEpisode.Id FROM JellyfinEpisode
|
||||
INNER JOIN MediaItem MI ON MI.Id = JellyfinEpisode.Id
|
||||
@@ -237,7 +237,7 @@ public class JellyfinTelevisionRepository : IJellyfinTelevisionRepository
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
|
||||
|
||||
season.State = MediaItemState.Normal;
|
||||
|
||||
|
||||
Option<int> maybeId = await dbContext.Connection.ExecuteScalarAsync<int>(
|
||||
@"SELECT JellyfinSeason.Id FROM JellyfinSeason
|
||||
INNER JOIN MediaItem MI ON MI.Id = JellyfinSeason.Id
|
||||
@@ -260,7 +260,7 @@ public class JellyfinTelevisionRepository : IJellyfinTelevisionRepository
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
|
||||
|
||||
show.State = MediaItemState.Normal;
|
||||
|
||||
|
||||
Option<int> maybeId = await dbContext.Connection.ExecuteScalarAsync<int>(
|
||||
@"SELECT JellyfinShow.Id FROM JellyfinShow
|
||||
INNER JOIN MediaItem MI ON MI.Id = JellyfinShow.Id
|
||||
|
||||
@@ -75,7 +75,7 @@ public class PlexTelevisionRepository : IPlexTelevisionRepository
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
|
||||
|
||||
show.State = MediaItemState.Normal;
|
||||
|
||||
|
||||
Option<int> maybeId = await dbContext.Connection.ExecuteScalarAsync<int>(
|
||||
@"SELECT PlexShow.Id FROM PlexShow
|
||||
INNER JOIN MediaItem MI ON MI.Id = PlexShow.Id
|
||||
|
||||
@@ -175,7 +175,7 @@ public class TelevisionRepository : ITelevisionRepository
|
||||
public async Task<List<Season>> GetPagedSeasons(int televisionShowId, int pageNumber, int pageSize)
|
||||
{
|
||||
var result = new List<Season>();
|
||||
|
||||
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
|
||||
|
||||
Option<ShowMetadata> maybeShowMetadata = await dbContext.ShowMetadata
|
||||
|
||||
@@ -14,8 +14,8 @@ namespace ErsatzTV.Infrastructure.Jellyfin;
|
||||
|
||||
public class JellyfinApiClient : IJellyfinApiClient
|
||||
{
|
||||
private readonly IJellyfinPathReplacementService _jellyfinPathReplacementService;
|
||||
private readonly IFallbackMetadataProvider _fallbackMetadataProvider;
|
||||
private readonly IJellyfinPathReplacementService _jellyfinPathReplacementService;
|
||||
private readonly ILogger<JellyfinApiClient> _logger;
|
||||
private readonly IMemoryCache _memoryCache;
|
||||
|
||||
|
||||
@@ -10,8 +10,8 @@ public class EntityLocker : IEntityLocker
|
||||
private readonly ConcurrentDictionary<Type, byte> _lockedRemoteMediaSourceTypes = new();
|
||||
private bool _embyCollections;
|
||||
private bool _jellyfinCollections;
|
||||
private bool _plexCollections;
|
||||
private bool _plex;
|
||||
private bool _plexCollections;
|
||||
private bool _trakt;
|
||||
|
||||
public event EventHandler OnLibraryChanged;
|
||||
@@ -155,7 +155,7 @@ public class EntityLocker : IEntityLocker
|
||||
}
|
||||
|
||||
public bool AreEmbyCollectionsLocked() => _embyCollections;
|
||||
|
||||
|
||||
public bool LockJellyfinCollections()
|
||||
{
|
||||
if (!_jellyfinCollections)
|
||||
@@ -181,7 +181,7 @@ public class EntityLocker : IEntityLocker
|
||||
}
|
||||
|
||||
public bool AreJellyfinCollectionsLocked() => _jellyfinCollections;
|
||||
|
||||
|
||||
public bool LockPlexCollections()
|
||||
{
|
||||
if (!_plexCollections)
|
||||
|
||||
@@ -35,7 +35,7 @@ public interface IPlexServerApi
|
||||
int take,
|
||||
[Query] [AliasAs("X-Plex-Token")]
|
||||
string token);
|
||||
|
||||
|
||||
[Get("/library/all?type=18&X-Plex-Container-Start=0&X-Plex-Container-Size=0")]
|
||||
[Headers("Accept: text/xml")]
|
||||
public Task<PlexXmlMediaContainerStatsResponse> GetCollectionCount(
|
||||
@@ -52,14 +52,14 @@ public interface IPlexServerApi
|
||||
int take,
|
||||
[Query] [AliasAs("X-Plex-Token")]
|
||||
string token);
|
||||
|
||||
|
||||
[Get("/library/collections/{key}/children?X-Plex-Container-Start=0&X-Plex-Container-Size=0")]
|
||||
[Headers("Accept: text/xml")]
|
||||
public Task<PlexXmlMediaContainerStatsResponse> GetCollectionItemsCount(
|
||||
string key,
|
||||
[Query] [AliasAs("X-Plex-Token")]
|
||||
string token);
|
||||
|
||||
|
||||
[Get("/library/collections/{key}/children")]
|
||||
[Headers("Accept: application/json")]
|
||||
public Task<PlexMediaContainerResponse<PlexMediaContainerMetadataContent<PlexCollectionItemMetadataResponse>>>
|
||||
|
||||
@@ -18,7 +18,7 @@ public class PlexCollectionItemMetadataResponse
|
||||
|
||||
[XmlAttribute("updatedAt")]
|
||||
public long UpdatedAt { get; set; }
|
||||
|
||||
|
||||
[XmlAttribute("type")]
|
||||
public string Type { get; set; }
|
||||
}
|
||||
|
||||
@@ -18,13 +18,13 @@ public class PlexCollectionMetadataResponse
|
||||
|
||||
[XmlAttribute("updatedAt")]
|
||||
public long UpdatedAt { get; set; }
|
||||
|
||||
|
||||
[XmlAttribute("smart")]
|
||||
public string Smart { get; set; }
|
||||
|
||||
|
||||
[XmlAttribute("librarySectionId")]
|
||||
public int LibrarySectionId { get; set; }
|
||||
|
||||
|
||||
[XmlAttribute("childCount")]
|
||||
public string ChildCount { get; set; }
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ public class PlexMetadataResponse
|
||||
|
||||
[XmlAttribute("updatedAt")]
|
||||
public long UpdatedAt { get; set; }
|
||||
|
||||
|
||||
[XmlAttribute("index")]
|
||||
public int Index { get; set; }
|
||||
|
||||
|
||||
@@ -253,7 +253,7 @@ public class PlexEtag
|
||||
byte[] hash = SHA1.Create().ComputeHash(ms);
|
||||
return BitConverter.ToString(hash).Replace("-", string.Empty);
|
||||
}
|
||||
|
||||
|
||||
public string ForCollection(PlexCollectionMetadataResponse response)
|
||||
{
|
||||
using MemoryStream ms = _recyclableMemoryStreamManager.GetStream();
|
||||
@@ -267,7 +267,7 @@ public class PlexEtag
|
||||
|
||||
// collection updated at
|
||||
bw.Write(response.UpdatedAt);
|
||||
|
||||
|
||||
// collection child count
|
||||
bw.Write(response.ChildCount ?? "0");
|
||||
|
||||
@@ -296,7 +296,7 @@ public class PlexEtag
|
||||
Art = 21,
|
||||
|
||||
File = 30,
|
||||
|
||||
|
||||
ChildCount = 40,
|
||||
Smart = 41 // smart collection bool
|
||||
}
|
||||
|
||||
@@ -53,7 +53,9 @@ public class PlexServerApiClient : IPlexServerApiClient
|
||||
return directory
|
||||
// .Filter(l => l.Hidden == 0)
|
||||
.Filter(l => l.Type.ToLowerInvariant() is "movie" or "show")
|
||||
.Filter(l => l.Type.ToLowerInvariant() is not "movie" || (l.Agent ?? string.Empty).ToLowerInvariant() is not "com.plexapp.agents.none")
|
||||
.Filter(
|
||||
l => l.Type.ToLowerInvariant() is not "movie" ||
|
||||
(l.Agent ?? string.Empty).ToLowerInvariant() is not "com.plexapp.agents.none")
|
||||
.Map(Project)
|
||||
.Somes()
|
||||
.ToList();
|
||||
@@ -407,7 +409,9 @@ public class PlexServerApiClient : IPlexServerApiClient
|
||||
_ => None
|
||||
};
|
||||
|
||||
private Option<PlexCollection> ProjectToCollection(PlexMediaSource plexMediaSource, PlexCollectionMetadataResponse item)
|
||||
private Option<PlexCollection> ProjectToCollection(
|
||||
PlexMediaSource plexMediaSource,
|
||||
PlexCollectionMetadataResponse item)
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -432,7 +436,7 @@ public class PlexServerApiClient : IPlexServerApiClient
|
||||
return None;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private Option<MediaItem> ProjectToCollectionMediaItem(PlexCollectionItemMetadataResponse item)
|
||||
{
|
||||
try
|
||||
|
||||
@@ -78,7 +78,8 @@ public class MultiEpisodeShuffleCollectionEnumerator : IMediaCollectionEnumerato
|
||||
_random = new CloneableRandom(state.Seed);
|
||||
_shuffled = Shuffle(_random);
|
||||
_lazyMinimumDuration =
|
||||
new Lazy<Option<TimeSpan>>(() => _shuffled.Bind(i => i.GetNonZeroDuration()).OrderBy(identity).HeadOrNone());
|
||||
new Lazy<Option<TimeSpan>>(
|
||||
() => _shuffled.Bind(i => i.GetNonZeroDuration()).OrderBy(identity).HeadOrNone());
|
||||
|
||||
State = new CollectionEnumeratorState { Seed = state.Seed };
|
||||
while (State.Index < state.Index)
|
||||
|
||||
@@ -741,7 +741,7 @@ public class ElasticSearchIndex : ISearchIndex
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
private async Task<List<string>> GetLanguages(ISearchRepository searchRepository, List<string> mediaCodes)
|
||||
{
|
||||
var englishNames = new System.Collections.Generic.HashSet<string>();
|
||||
@@ -757,7 +757,7 @@ public class ElasticSearchIndex : ISearchIndex
|
||||
|
||||
return englishNames.ToList();
|
||||
}
|
||||
|
||||
|
||||
private static List<string> GetLanguageTags(IEnumerable<MediaVersion> mediaVersions) =>
|
||||
mediaVersions
|
||||
.Map(mv => mv.Streams.Filter(ms => ms.MediaStreamKind == MediaStreamKind.Audio).Map(ms => ms.Language))
|
||||
|
||||
@@ -518,7 +518,7 @@ public sealed class LuceneSearchIndex : ISearchIndex
|
||||
{
|
||||
doc.Add(new TextField(LanguageTagField, code, Field.Store.NO));
|
||||
}
|
||||
|
||||
|
||||
var englishNames = new System.Collections.Generic.HashSet<string>();
|
||||
foreach (string code in await searchRepository.GetAllLanguageCodes(mediaCodes))
|
||||
{
|
||||
@@ -809,7 +809,10 @@ public sealed class LuceneSearchIndex : ISearchIndex
|
||||
new StringField(IdField, musicVideo.Id.ToString(CultureInfo.InvariantCulture), Field.Store.YES),
|
||||
new StringField(TypeField, MusicVideoType, Field.Store.YES),
|
||||
new TextField(TitleField, metadata.Title ?? string.Empty, Field.Store.NO),
|
||||
new StringField(SortTitleField, (metadata.SortTitle ?? string.Empty).ToLowerInvariant(), Field.Store.NO),
|
||||
new StringField(
|
||||
SortTitleField,
|
||||
(metadata.SortTitle ?? string.Empty).ToLowerInvariant(),
|
||||
Field.Store.NO),
|
||||
new TextField(LibraryNameField, musicVideo.LibraryPath.Library.Name, Field.Store.NO),
|
||||
new StringField(
|
||||
LibraryIdField,
|
||||
|
||||
@@ -27,13 +27,13 @@ public class ElasticSearchItem : MinimalElasticSearchItem
|
||||
|
||||
[JsonPropertyName(LuceneSearchIndex.LanguageField)]
|
||||
public List<string> Language { get; set; }
|
||||
|
||||
|
||||
[JsonPropertyName(LuceneSearchIndex.LanguageTagField)]
|
||||
public List<string> LanguageTag { get; set; }
|
||||
|
||||
[JsonPropertyName(LuceneSearchIndex.MinutesField)]
|
||||
public int Minutes { get; set; }
|
||||
|
||||
|
||||
[JsonPropertyName(LuceneSearchIndex.SecondsField)]
|
||||
public int Seconds { get; set; }
|
||||
|
||||
|
||||
@@ -2,4 +2,5 @@
|
||||
|
||||
namespace ErsatzTV.Scanner.Application.Jellyfin;
|
||||
|
||||
public record SynchronizeJellyfinCollections(int JellyfinMediaSourceId, bool ForceScan) : IRequest<Either<BaseError, Unit>>;
|
||||
public record SynchronizeJellyfinCollections
|
||||
(int JellyfinMediaSourceId, bool ForceScan) : IRequest<Either<BaseError, Unit>>;
|
||||
|
||||
+3
-3
@@ -9,10 +9,10 @@ namespace ErsatzTV.Scanner.Application.Jellyfin;
|
||||
public class
|
||||
SynchronizeJellyfinCollectionsHandler : IRequestHandler<SynchronizeJellyfinCollections, Either<BaseError, Unit>>
|
||||
{
|
||||
private readonly IConfigElementRepository _configElementRepository;
|
||||
private readonly IJellyfinSecretStore _jellyfinSecretStore;
|
||||
private readonly IMediaSourceRepository _mediaSourceRepository;
|
||||
private readonly IJellyfinCollectionScanner _scanner;
|
||||
private readonly IConfigElementRepository _configElementRepository;
|
||||
|
||||
public SynchronizeJellyfinCollectionsHandler(
|
||||
IMediaSourceRepository mediaSourceRepository,
|
||||
@@ -42,7 +42,7 @@ public class
|
||||
Task<Validation<BaseError, ConnectionParameters>> mediaSource = MediaSourceMustExist(request)
|
||||
.BindT(MediaSourceMustHaveActiveConnection)
|
||||
.BindT(MediaSourceMustHaveApiKey);
|
||||
|
||||
|
||||
return (await mediaSource, await ValidateLibraryRefreshInterval())
|
||||
.Apply(
|
||||
(connectionParameters, libraryRefreshInterval) => new RequestParameters(
|
||||
@@ -51,7 +51,7 @@ public class
|
||||
request.ForceScan,
|
||||
libraryRefreshInterval));
|
||||
}
|
||||
|
||||
|
||||
private Task<Validation<BaseError, int>> ValidateLibraryRefreshInterval() =>
|
||||
_configElementRepository.GetValue<int>(ConfigElementKey.LibraryRefreshInterval)
|
||||
.FilterT(lri => lri is >= 0 and < 1_000_000)
|
||||
|
||||
@@ -9,8 +9,8 @@ namespace ErsatzTV.Scanner.Application.Plex;
|
||||
public class SynchronizePlexCollectionsHandler : IRequestHandler<SynchronizePlexCollections, Either<BaseError, Unit>>
|
||||
{
|
||||
private readonly IConfigElementRepository _configElementRepository;
|
||||
private readonly IPlexSecretStore _plexSecretStore;
|
||||
private readonly IMediaSourceRepository _mediaSourceRepository;
|
||||
private readonly IPlexSecretStore _plexSecretStore;
|
||||
private readonly IPlexCollectionScanner _scanner;
|
||||
|
||||
public SynchronizePlexCollectionsHandler(
|
||||
|
||||
@@ -41,7 +41,7 @@ public class ShowNfoReader : NfoReader<ShowNfo>, IShowNfoReader
|
||||
var settings = new XmlReaderSettings { Async = true, ConformanceLevel = ConformanceLevel.Fragment };
|
||||
using var reader = XmlReader.Create(input, settings);
|
||||
var done = false;
|
||||
int showDepth = 0;
|
||||
var showDepth = 0;
|
||||
|
||||
while (!done && await reader.ReadAsync())
|
||||
{
|
||||
|
||||
@@ -131,7 +131,8 @@ public class OtherVideoFolderScanner : LocalFolderScanner, IOtherVideoFolderScan
|
||||
.HeadOrNone();
|
||||
|
||||
// skip folder if etag matches
|
||||
if (allFiles.Count == 0 || await knownFolder.Map(f => f.Etag ?? string.Empty).IfNoneAsync(string.Empty) ==
|
||||
if (allFiles.Count == 0 ||
|
||||
await knownFolder.Map(f => f.Etag ?? string.Empty).IfNoneAsync(string.Empty) ==
|
||||
etag)
|
||||
{
|
||||
continue;
|
||||
|
||||
@@ -126,7 +126,8 @@ public class SongFolderScanner : LocalFolderScanner, ISongFolderScanner
|
||||
.HeadOrNone();
|
||||
|
||||
// skip folder if etag matches
|
||||
if (allFiles.Count == 0 || await knownFolder.Map(f => f.Etag ?? string.Empty).IfNoneAsync(string.Empty) ==
|
||||
if (allFiles.Count == 0 ||
|
||||
await knownFolder.Map(f => f.Etag ?? string.Empty).IfNoneAsync(string.Empty) ==
|
||||
etag)
|
||||
{
|
||||
continue;
|
||||
|
||||
@@ -10,10 +10,10 @@ namespace ErsatzTV.Scanner.Core.Plex;
|
||||
|
||||
public class PlexCollectionScanner : IPlexCollectionScanner
|
||||
{
|
||||
private readonly IPlexServerApiClient _plexServerApiClient;
|
||||
private readonly IPlexCollectionRepository _plexCollectionRepository;
|
||||
private readonly ILogger<PlexCollectionScanner> _logger;
|
||||
private readonly IMediator _mediator;
|
||||
private readonly IPlexCollectionRepository _plexCollectionRepository;
|
||||
private readonly IPlexServerApiClient _plexServerApiClient;
|
||||
|
||||
public PlexCollectionScanner(
|
||||
IMediator mediator,
|
||||
|
||||
@@ -73,7 +73,7 @@ public class Worker : BackgroundService
|
||||
var scanPlexCollectionsCommand = new Command("scan-plex-collections", "Scan Plex collections");
|
||||
scanPlexCollectionsCommand.AddArgument(mediaSourceIdArgument);
|
||||
scanPlexCollectionsCommand.AddOption(forceOption);
|
||||
|
||||
|
||||
var scanEmbyCommand = new Command("scan-emby", "Scan an Emby library");
|
||||
scanEmbyCommand.AddArgument(libraryIdArgument);
|
||||
scanEmbyCommand.AddOption(forceOption);
|
||||
@@ -128,7 +128,7 @@ public class Worker : BackgroundService
|
||||
await mediator.Send(scan, context.GetCancellationToken());
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
scanPlexCollectionsCommand.SetHandler(
|
||||
async context =>
|
||||
{
|
||||
@@ -202,7 +202,7 @@ public class Worker : BackgroundService
|
||||
await mediator.Send(scan, context.GetCancellationToken());
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
scanJellyfinCollectionsCommand.SetHandler(
|
||||
async context =>
|
||||
{
|
||||
|
||||
@@ -196,7 +196,7 @@
|
||||
{
|
||||
_resolutions = restoredResolutions;
|
||||
}
|
||||
|
||||
|
||||
if (!ApplicationState.TryTakeFromJson("_hardwareAccelerationKinds", out List<HardwareAccelerationKind> restoredHardwareAccelerationKinds))
|
||||
{
|
||||
_hardwareAccelerationKinds = await _mediator.Send(new GetSupportedHardwareAccelerationKinds(), _cts.Token);
|
||||
|
||||
@@ -138,7 +138,7 @@ public class ScannerService : BackgroundService
|
||||
entityLocker.UnlockLibrary(request.LibraryId);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private async Task SynchronizeLibraries(SynchronizePlexLibraries request, CancellationToken cancellationToken)
|
||||
{
|
||||
using IServiceScope scope = _serviceScopeFactory.CreateScope();
|
||||
@@ -188,7 +188,7 @@ public class ScannerService : BackgroundService
|
||||
entityLocker.UnlockLibrary(request.PlexLibraryId);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private async Task SynchronizePlexCollections(
|
||||
SynchronizePlexCollections request,
|
||||
CancellationToken cancellationToken)
|
||||
@@ -285,7 +285,7 @@ public class ScannerService : BackgroundService
|
||||
entityLocker.UnlockLibrary(request.JellyfinLibraryId);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private async Task SynchronizeJellyfinCollections(
|
||||
SynchronizeJellyfinCollections request,
|
||||
CancellationToken cancellationToken)
|
||||
|
||||
@@ -247,7 +247,7 @@ public class SchedulerService : BackgroundService
|
||||
cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
foreach (int mediaSourceId in mediaSourceIds)
|
||||
{
|
||||
await _scannerWorkerChannel.WriteAsync(
|
||||
@@ -274,7 +274,7 @@ public class SchedulerService : BackgroundService
|
||||
cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
foreach (int mediaSourceId in mediaSourceIds)
|
||||
{
|
||||
await _scannerWorkerChannel.WriteAsync(
|
||||
|
||||
@@ -35,7 +35,7 @@ public class ProgramScheduleItemEditViewModel : INotifyPropertyChanged
|
||||
PlayoutMode is PlayoutMode.Multiple or PlayoutMode.Duration
|
||||
&& CollectionType is ProgramScheduleItemCollectionType.Collection
|
||||
or ProgramScheduleItemCollectionType.MultiCollection or ProgramScheduleItemCollectionType.SmartCollection;
|
||||
|
||||
|
||||
public PlayoutMode PlayoutMode { get; set; }
|
||||
|
||||
public ProgramScheduleItemCollectionType CollectionType
|
||||
|
||||
Reference in New Issue
Block a user