Browse Source

Merge branch 'main' into fix/singleton-mutex-config-folder

pull/2946/head
Jason Dove 2 weeks ago
parent
commit
8aaaa7febe
No known key found for this signature in database
  1. 8
      CHANGELOG.md
  2. 4
      ErsatzTV.Application/ErsatzTV.Application.csproj
  3. 18
      ErsatzTV.Core.Tests/ErsatzTV.Core.Tests.csproj
  4. 100
      ErsatzTV.Core.Tests/Scheduling/PlaylistEnumeratorTests.cs
  5. 3
      ErsatzTV.Core/Domain/CollectionEnumeratorState.cs
  6. 18
      ErsatzTV.Core/ErsatzTV.Core.csproj
  7. 2
      ErsatzTV.Core/Interfaces/Repositories/IMovieRepository.cs
  8. 1
      ErsatzTV.Core/Interfaces/Repositories/IOtherVideoRepository.cs
  9. 1
      ErsatzTV.Core/Scheduling/BlockScheduling/BlockPlayoutEnumerator.cs
  10. 3
      ErsatzTV.Core/Scheduling/BlockScheduling/BlockPlayoutShuffledMediaCollectionEnumerator.cs
  11. 9
      ErsatzTV.Core/Scheduling/ChronologicalMediaCollectionEnumerator.cs
  12. 12
      ErsatzTV.Core/Scheduling/CustomOrderCollectionEnumerator.cs
  13. 2
      ErsatzTV.Core/Scheduling/Engine/MarathonHelper.cs
  14. 1
      ErsatzTV.Core/Scheduling/Engine/PlaylistHelper.cs
  15. 1
      ErsatzTV.Core/Scheduling/Engine/SchedulingEngine.cs
  16. 8
      ErsatzTV.Core/Scheduling/OrderedScheduleItemsEnumerator.cs
  17. 28
      ErsatzTV.Core/Scheduling/PlaylistEnumerator.cs
  18. 27
      ErsatzTV.Core/Scheduling/PlayoutBuilder.cs
  19. 8
      ErsatzTV.Core/Scheduling/RandomizedMediaCollectionEnumerator.cs
  20. 8
      ErsatzTV.Core/Scheduling/RandomizedRotatingMediaCollectionEnumerator.cs
  21. 8
      ErsatzTV.Core/Scheduling/SeasonEpisodeMediaCollectionEnumerator.cs
  22. 5
      ErsatzTV.Core/Scheduling/ShuffleInOrderCollectionEnumerator.cs
  23. 5
      ErsatzTV.Core/Scheduling/ShuffledMediaCollectionEnumerator.cs
  24. 3
      ErsatzTV.Core/Scheduling/ShuffledScheduleItemsEnumerator.cs
  25. 1
      ErsatzTV.Core/Scheduling/YamlScheduling/EnumeratorCache.cs
  26. 8
      ErsatzTV.FFmpeg.Tests/ErsatzTV.FFmpeg.Tests.csproj
  27. 4
      ErsatzTV.FFmpeg/ErsatzTV.FFmpeg.csproj
  28. 7060
      ErsatzTV.Infrastructure.MySql/Migrations/20260722002414_Add_CollectionEnumeratorState_Started.Designer.cs
  29. 57
      ErsatzTV.Infrastructure.MySql/Migrations/20260722002414_Add_CollectionEnumeratorState_Started.cs
  30. 9
      ErsatzTV.Infrastructure.MySql/Migrations/TvContextModelSnapshot.cs
  31. 2
      ErsatzTV.Infrastructure.Sqlite/ErsatzTV.Infrastructure.Sqlite.csproj
  32. 6887
      ErsatzTV.Infrastructure.Sqlite/Migrations/20260722002327_Add_CollectionEnumeratorState_Started.Designer.cs
  33. 57
      ErsatzTV.Infrastructure.Sqlite/Migrations/20260722002327_Add_CollectionEnumeratorState_Started.cs
  34. 9
      ErsatzTV.Infrastructure.Sqlite/Migrations/TvContextModelSnapshot.cs
  35. 6
      ErsatzTV.Infrastructure.Tests/ErsatzTV.Infrastructure.Tests.csproj
  36. 6
      ErsatzTV.Infrastructure/Data/Repositories/MovieRepository.cs
  37. 8
      ErsatzTV.Infrastructure/Data/Repositories/OtherVideoRepository.cs
  38. 6
      ErsatzTV.Infrastructure/ErsatzTV.Infrastructure.csproj
  39. 5
      ErsatzTV.Infrastructure/Plex/PlexEtag.cs
  40. 3
      ErsatzTV.Infrastructure/Scheduling/MultiEpisodeShuffleCollectionEnumerator.cs
  41. 9
      ErsatzTV.Scanner.Tests/Core/FFmpeg/TranscodingTests.cs
  42. 64
      ErsatzTV.Scanner.Tests/Core/Metadata/MovieFolderScannerTests.cs
  43. 6
      ErsatzTV.Scanner.Tests/ErsatzTV.Scanner.Tests.csproj
  44. 5
      ErsatzTV.Scanner/Core/Plex/PlexMovieLibraryScanner.cs
  45. 8
      ErsatzTV.Scanner/Core/Plex/PlexOtherVideoLibraryScanner.cs
  46. 10
      ErsatzTV.Scanner/ErsatzTV.Scanner.csproj
  47. 1
      ErsatzTV.Tests/ErsatzTV.Tests.csproj
  48. 21
      ErsatzTV/ErsatzTV.csproj

8
CHANGELOG.md

@ -4,6 +4,14 @@ All notable changes to this project will be documented in this file. @@ -4,6 +4,14 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
## [Unreleased]
### Fixed
- Update Plex movie and other video titles when changed from Plex
- The first library scan after updating will act like a deep scan due to adding title to the Plex etag calculation
- Future periodic scans will update titles in ETV automatically (deep scans will not be required to update titles)
- Randomize start points on playlist items in classic schedules when setting is enabled
- Playlists ignored this setting in earlier builds
- Always randomize start points for all collections the first time they are used
- Previously, only collections scheduled during the first day of a playout build had their start points randomized
## [26.6.0] - 2026-07-09
### Added

4
ErsatzTV.Application/ErsatzTV.Application.csproj

@ -14,8 +14,8 @@ @@ -14,8 +14,8 @@
<PackageReference Include="CliWrap" Version="3.10.2" />
<PackageReference Include="Humanizer.Core" Version="3.0.10" />
<PackageReference Include="MediatR" Version="[12.5.0]" />
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="10.0.9" />
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.9" />
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="10.0.10" />
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.10" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
<PackageReference Include="Serilog.Formatting.Compact.Reader" Version="4.0.0" />
<PackageReference Include="WebMarkupMin.Core" Version="2.21.0" />

18
ErsatzTV.Core.Tests/ErsatzTV.Core.Tests.csproj

@ -9,20 +9,20 @@ @@ -9,20 +9,20 @@
<ItemGroup>
<PackageReference Include="CliWrap" Version="3.10.2" />
<PackageReference Include="LanguageExt.Core" Version="4.4.9" />
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="10.0.9" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.9" />
<PackageReference Include="Microsoft.Extensions.Logging" Version="10.0.9" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.9" />
<PackageReference Include="Microsoft.Extensions.Logging.Debug" Version="10.0.9" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.7.0" />
<PackageReference Include="NSubstitute" Version="5.3.0" />
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="10.0.10" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.10" />
<PackageReference Include="Microsoft.Extensions.Logging" Version="10.0.10" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.10" />
<PackageReference Include="Microsoft.Extensions.Logging.Debug" Version="10.0.10" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.8.1" />
<PackageReference Include="NSubstitute" Version="6.0.0" />
<PackageReference Include="NUnit" Version="4.6.1" />
<PackageReference Include="NUnit3TestAdapter" Version="6.2.0" />
<PackageReference Include="Serilog" Version="4.3.1" />
<PackageReference Include="Serilog" Version="4.4.0" />
<PackageReference Include="Serilog.Extensions.Logging" Version="10.0.0" />
<PackageReference Include="Serilog.Sinks.Debug" Version="3.0.0" />
<PackageReference Include="Shouldly" Version="4.3.0" />
<PackageReference Include="Testably.Abstractions.Testing" Version="6.6.0" />
<PackageReference Include="Testably.Abstractions.Testing" Version="7.0.1" />
</ItemGroup>
<ItemGroup>

100
ErsatzTV.Core.Tests/Scheduling/PlaylistEnumeratorTests.cs

@ -62,6 +62,7 @@ public class PlaylistEnumeratorTests @@ -62,6 +62,7 @@ public class PlaylistEnumeratorTests
new CollectionEnumeratorState(),
shufflePlaylistItems: false,
batchSize: Option<int>.None,
randomStartPoint: false,
CancellationToken.None);
var items = new List<int>();
@ -130,6 +131,7 @@ public class PlaylistEnumeratorTests @@ -130,6 +131,7 @@ public class PlaylistEnumeratorTests
new CollectionEnumeratorState(),
shufflePlaylistItems: false,
batchSize: Option<int>.None,
randomStartPoint: false,
CancellationToken.None);
var items = new List<int>();
@ -200,6 +202,7 @@ public class PlaylistEnumeratorTests @@ -200,6 +202,7 @@ public class PlaylistEnumeratorTests
state,
shufflePlaylistItems: true,
batchSize: Option<int>.None,
randomStartPoint: false,
CancellationToken.None);
var items = new List<int>();
@ -270,6 +273,7 @@ public class PlaylistEnumeratorTests @@ -270,6 +273,7 @@ public class PlaylistEnumeratorTests
state,
shufflePlaylistItems: true,
batchSize: Option<int>.None,
randomStartPoint: false,
CancellationToken.None);
var items = new List<int>();
@ -352,11 +356,107 @@ public class PlaylistEnumeratorTests @@ -352,11 +356,107 @@ public class PlaylistEnumeratorTests
state,
shufflePlaylistItems: true,
batchSize: Option<int>.None,
randomStartPoint: false,
CancellationToken.None);
enumerator.CountForFiller.ShouldBe(7);
}
[Test]
public async Task RandomStartPoint_Should_Continue_After_Rebuild()
{
IMediaCollectionRepository repo = Substitute.For<IMediaCollectionRepository>();
Dictionary<PlaylistItem, List<MediaItem>> BuildMap() => new()
{
{
new PlaylistItem
{
Id = 1,
Index = 0,
PlaybackOrder = PlaybackOrder.Chronological,
PlayAll = false,
CollectionType = CollectionType.Collection,
CollectionId = 1
},
[FakeMovie(10), FakeMovie(11), FakeMovie(12), FakeMovie(13), FakeMovie(14)]
},
{
new PlaylistItem
{
Id = 2,
Index = 1,
PlaybackOrder = PlaybackOrder.Chronological,
PlayAll = false,
CollectionType = CollectionType.Collection,
CollectionId = 2
},
[FakeMovie(15), FakeMovie(16), FakeMovie(17), FakeMovie(18), FakeMovie(19)]
},
{
new PlaylistItem
{
Id = 3,
Index = 2,
PlaybackOrder = PlaybackOrder.Chronological,
PlayAll = false,
CollectionType = CollectionType.Collection,
CollectionId = 3
},
[FakeMovie(20), FakeMovie(21), FakeMovie(22), FakeMovie(23), FakeMovie(24)]
}
};
const int SEED = 987654321;
// day 1: fresh build with a random start point
PlaylistEnumerator day1 = await PlaylistEnumerator.Create(
repo,
BuildMap(),
new CollectionEnumeratorState { Seed = SEED, Index = 0 },
shufflePlaylistItems: false,
batchSize: Option<int>.None,
randomStartPoint: true,
CancellationToken.None);
// capture a reference sequence and the state we would persist halfway through
var reference = new List<int>();
CollectionEnumeratorState persistedState = null;
for (var i = 0; i < 12; i++)
{
reference.AddRange(day1.Current.Map(mi => mi.Id));
day1.MoveNext(Option<DateTimeOffset>.None);
if (i == 5)
{
persistedState = day1.State.Clone();
}
}
// sanity: the random start point actually moved us off the natural start
reference.First().ShouldNotBe(10);
persistedState.Started.ShouldBeTrue();
// day 2: rebuild from the persisted state; the offsets must be reproduced so we continue
PlaylistEnumerator day2 = await PlaylistEnumerator.Create(
repo,
BuildMap(),
persistedState,
shufflePlaylistItems: false,
batchSize: Option<int>.None,
randomStartPoint: true,
CancellationToken.None);
var continued = new List<int>();
for (var i = 0; i < 6; i++)
{
continued.AddRange(day2.Current.Map(mi => mi.Id));
day2.MoveNext(Option<DateTimeOffset>.None);
}
// day 2 must pick up exactly where day 1 left off, not reset toward the start
continued.ShouldBe(reference.Skip(6).Take(6));
}
private static Movie FakeMovie(int id) => new()
{
Id = id,

3
ErsatzTV.Core/Domain/CollectionEnumeratorState.cs

@ -4,5 +4,6 @@ public class CollectionEnumeratorState @@ -4,5 +4,6 @@ public class CollectionEnumeratorState
{
public int Seed { get; set; }
public int Index { get; set; }
public CollectionEnumeratorState Clone() => new() { Seed = Seed, Index = Index };
public bool Started { get; set; }
public CollectionEnumeratorState Clone() => new() { Seed = Seed, Index = Index, Started = Started };
}

18
ErsatzTV.Core/ErsatzTV.Core.csproj

@ -16,19 +16,19 @@ @@ -16,19 +16,19 @@
<PackageReference Include="LanguageExt.Core" Version="4.4.9" />
<PackageReference Include="LanguageExt.Transformers" Version="4.4.8" />
<PackageReference Include="MediatR" Version="[12.5.0]" />
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="10.0.9" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.9" />
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.9" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.9" />
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="10.0.10" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.10" />
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.10" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.10" />
<PackageReference Include="Microsoft.IO.RecyclableMemoryStream" Version="3.0.1" />
<PackageReference Include="NCalcSync" Version="6.4.0" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
<PackageReference Include="Serilog" Version="4.3.1" />
<PackageReference Include="Serilog" Version="4.4.0" />
<PackageReference Include="Serilog.Sinks.Console" Version="6.1.1" />
<PackageReference Include="SkiaSharp" Version="4.150.0" />
<PackageReference Include="SkiaSharp.NativeAssets.Linux.NoDependencies" Version="4.150.0" />
<PackageReference Include="System.CommandLine" Version="2.0.9" />
<PackageReference Include="Testably.Abstractions" Version="10.2.0" />
<PackageReference Include="SkiaSharp" Version="4.150.1" />
<PackageReference Include="SkiaSharp.NativeAssets.Linux.NoDependencies" Version="4.150.1" />
<PackageReference Include="System.CommandLine" Version="2.0.10" />
<PackageReference Include="Testably.Abstractions" Version="10.3.0" />
<PackageReference Include="TimeSpanParserUtil" Version="1.2.0" />
<PackageReference Include="YamlDotNet" Version="18.1.0" />
</ItemGroup>

2
ErsatzTV.Core/Interfaces/Repositories/IMovieRepository.cs

@ -20,7 +20,7 @@ public interface IMovieRepository @@ -20,7 +20,7 @@ public interface IMovieRepository
Task<bool> AddTag(MovieMetadata metadata, Tag tag);
Task<bool> AddStudio(MovieMetadata metadata, Studio studio);
Task<bool> AddActor(MovieMetadata metadata, Actor actor);
Task<bool> UpdateSortTitle(MovieMetadata movieMetadata);
Task<bool> UpdateTitles(MovieMetadata movieMetadata, string title, string sortTitle);
Task<bool> AddDirector(MovieMetadata metadata, Director director);
Task<bool> AddWriter(MovieMetadata metadata, Writer writer);
}

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

@ -19,4 +19,5 @@ public interface IOtherVideoRepository @@ -19,4 +19,5 @@ public interface IOtherVideoRepository
Task<bool> AddActor(OtherVideoMetadata metadata, Actor actor);
Task<bool> AddDirector(OtherVideoMetadata metadata, Director director);
Task<bool> AddWriter(OtherVideoMetadata metadata, Writer writer);
Task<bool> UpdateTitles(OtherVideoMetadata metadata, string title, string sortTitle);
}

1
ErsatzTV.Core/Scheduling/BlockScheduling/BlockPlayoutEnumerator.cs

@ -168,6 +168,7 @@ public static class BlockPlayoutEnumerator @@ -168,6 +168,7 @@ public static class BlockPlayoutEnumerator
state,
shufflePlaylistItems: false,
batchSize: Option<int>.None,
randomStartPoint: false,
cancellationToken);
DateTime historyTime = currentTime.UtcDateTime;

3
ErsatzTV.Core/Scheduling/BlockScheduling/BlockPlayoutShuffledMediaCollectionEnumerator.cs

@ -35,6 +35,7 @@ public class BlockPlayoutShuffledMediaCollectionEnumerator : IMediaCollectionEnu @@ -35,6 +35,7 @@ public class BlockPlayoutShuffledMediaCollectionEnumerator : IMediaCollectionEnu
{
State.Seed = state.Seed;
State.Index = state.Index;
State.Started = state.Started;
_shuffled = Shuffle(_mediaItems);
}
@ -54,6 +55,8 @@ public class BlockPlayoutShuffledMediaCollectionEnumerator : IMediaCollectionEnu @@ -54,6 +55,8 @@ public class BlockPlayoutShuffledMediaCollectionEnumerator : IMediaCollectionEnu
{
_shuffled = Shuffle(_mediaItems);
}
State.Started = true;
}
public Option<TimeSpan> MinimumDuration => _lazyMinimumDuration.Value;

9
ErsatzTV.Core/Scheduling/ChronologicalMediaCollectionEnumerator.cs

@ -22,7 +22,7 @@ public sealed class ChronologicalMediaCollectionEnumerator : IMediaCollectionEnu @@ -22,7 +22,7 @@ public sealed class ChronologicalMediaCollectionEnumerator : IMediaCollectionEnu
_lazyMediaItemGroupSize = new Lazy<Dictionary<int, int>>(CalculateMediaItemGroupSizes);
State = new CollectionEnumeratorState { Seed = state.Seed };
State = new CollectionEnumeratorState { Seed = state.Seed, Started = state.Started };
if (state.Index >= _sortedMediaItems.Count)
{
@ -36,9 +36,12 @@ public sealed class ChronologicalMediaCollectionEnumerator : IMediaCollectionEnu @@ -36,9 +36,12 @@ public sealed class ChronologicalMediaCollectionEnumerator : IMediaCollectionEnu
}
}
public void ResetState(CollectionEnumeratorState state) =>
public void ResetState(CollectionEnumeratorState state)
{
// seed doesn't matter in chronological
State.Index = state.Index;
State.Started = state.Started;
}
public string SchedulingContextName => "Chronological";
@ -53,6 +56,8 @@ public sealed class ChronologicalMediaCollectionEnumerator : IMediaCollectionEnu @@ -53,6 +56,8 @@ public sealed class ChronologicalMediaCollectionEnumerator : IMediaCollectionEnu
{
State.Index = (State.Index + 1) % _sortedMediaItems.Count;
}
State.Started = true;
}
public Option<TimeSpan> MinimumDuration => _lazyMinimumDuration.Value;

12
ErsatzTV.Core/Scheduling/CustomOrderCollectionEnumerator.cs

@ -25,16 +25,19 @@ public class CustomOrderCollectionEnumerator : IMediaCollectionEnumerator @@ -25,16 +25,19 @@ public class CustomOrderCollectionEnumerator : IMediaCollectionEnumerator
_lazyMinimumDuration = new Lazy<Option<TimeSpan>>(() =>
_sortedMediaItems.Bind(i => i.GetNonZeroDuration()).OrderBy(identity).HeadOrNone());
State = new CollectionEnumeratorState { Seed = state.Seed };
State = new CollectionEnumeratorState { Seed = state.Seed, Started = state.Started };
while (State.Index < state.Index)
{
MoveNext(Option<DateTimeOffset>.None);
}
}
public void ResetState(CollectionEnumeratorState state) =>
public void ResetState(CollectionEnumeratorState state)
{
// seed doesn't matter here
State.Index = state.Index;
State.Started = state.Started;
}
public string SchedulingContextName => "Custom Order";
@ -43,8 +46,11 @@ public class CustomOrderCollectionEnumerator : IMediaCollectionEnumerator @@ -43,8 +46,11 @@ public class CustomOrderCollectionEnumerator : IMediaCollectionEnumerator
public Option<MediaItem> Current => _sortedMediaItems.Count != 0 ? _sortedMediaItems[State.Index] : None;
public Option<bool> CurrentIncludeInProgramGuide { get; }
public void MoveNext(Option<DateTimeOffset> scheduledAt) =>
public void MoveNext(Option<DateTimeOffset> scheduledAt)
{
State.Index = (State.Index + 1) % _sortedMediaItems.Count;
State.Started = true;
}
public Option<TimeSpan> MinimumDuration => _lazyMinimumDuration.Value;

2
ErsatzTV.Core/Scheduling/Engine/MarathonHelper.cs

@ -66,6 +66,7 @@ public class MarathonHelper(IMediaCollectionRepository mediaCollectionRepository @@ -66,6 +66,7 @@ public class MarathonHelper(IMediaCollectionRepository mediaCollectionRepository
state,
marathonShuffleGroups,
marathonBatchSize,
randomStartPoint: false,
cancellationToken);
}
@ -136,6 +137,7 @@ public class MarathonHelper(IMediaCollectionRepository mediaCollectionRepository @@ -136,6 +137,7 @@ public class MarathonHelper(IMediaCollectionRepository mediaCollectionRepository
state,
shuffleGroups,
batchSize: Option<int>.None,
randomStartPoint: false,
cancellationToken);
return new PlaylistContentResult(

1
ErsatzTV.Core/Scheduling/Engine/PlaylistHelper.cs

@ -58,6 +58,7 @@ public class PlaylistHelper(IMediaCollectionRepository mediaCollectionRepository @@ -58,6 +58,7 @@ public class PlaylistHelper(IMediaCollectionRepository mediaCollectionRepository
state,
shufflePlaylistItems: false,
batchSize: Option<int>.None,
randomStartPoint: false,
cancellationToken);
return new PlaylistContentResult(

1
ErsatzTV.Core/Scheduling/Engine/SchedulingEngine.cs

@ -257,6 +257,7 @@ public class SchedulingEngine( @@ -257,6 +257,7 @@ public class SchedulingEngine(
state,
shufflePlaylistItems: false,
batchSize: Option<int>.None,
randomStartPoint: false,
CancellationToken.None);
string historyKey = HistoryDetails.KeyForSchedulingContent(key, PlaybackOrder.None);

8
ErsatzTV.Core/Scheduling/OrderedScheduleItemsEnumerator.cs

@ -13,7 +13,7 @@ public class OrderedScheduleItemsEnumerator : IScheduleItemsEnumerator @@ -13,7 +13,7 @@ public class OrderedScheduleItemsEnumerator : IScheduleItemsEnumerator
{
_sortedScheduleItems = scheduleItems.OrderBy(i => i.Index).ToList();
State = new CollectionEnumeratorState { Seed = state.Seed };
State = new CollectionEnumeratorState { Seed = state.Seed, Started = state.Started };
if (state.Index >= _sortedScheduleItems.Count)
{
@ -31,7 +31,11 @@ public class OrderedScheduleItemsEnumerator : IScheduleItemsEnumerator @@ -31,7 +31,11 @@ public class OrderedScheduleItemsEnumerator : IScheduleItemsEnumerator
public ProgramScheduleItem Current => _sortedScheduleItems[State.Index];
public void MoveNext() => State.Index = (State.Index + 1) % _sortedScheduleItems.Count;
public void MoveNext()
{
State.Index = (State.Index + 1) % _sortedScheduleItems.Count;
State.Started = true;
}
public ProgramScheduleItem Peek(int offset) =>
_sortedScheduleItems[(State.Index + offset) % _sortedScheduleItems.Count];

28
ErsatzTV.Core/Scheduling/PlaylistEnumerator.cs

@ -31,9 +31,12 @@ public class PlaylistEnumerator : IMediaCollectionEnumerator @@ -31,9 +31,12 @@ public class PlaylistEnumerator : IMediaCollectionEnumerator
public int EnumeratorIndex { get; private set; }
public void ResetState(CollectionEnumeratorState state) =>
public void ResetState(CollectionEnumeratorState state)
{
// seed doesn't matter here
State.Index = state.Index;
State.Started = state.Started;
}
public string SchedulingContextName => "Playlist";
@ -111,6 +114,8 @@ public class PlaylistEnumerator : IMediaCollectionEnumerator @@ -111,6 +114,8 @@ public class PlaylistEnumerator : IMediaCollectionEnumerator
_sortedEnumerators = ShufflePlaylistItems();
}
}
State.Started = true;
}
public void SetEnumeratorIndex(int enumeratorIndex) => EnumeratorIndex = enumeratorIndex % _sortedEnumerators.Count;
@ -121,6 +126,7 @@ public class PlaylistEnumerator : IMediaCollectionEnumerator @@ -121,6 +126,7 @@ public class PlaylistEnumerator : IMediaCollectionEnumerator
CollectionEnumeratorState state,
bool shufflePlaylistItems,
Option<int> batchSize,
bool randomStartPoint,
CancellationToken cancellationToken)
{
var result = new PlaylistEnumerator
@ -131,6 +137,9 @@ public class PlaylistEnumerator : IMediaCollectionEnumerator @@ -131,6 +137,9 @@ public class PlaylistEnumerator : IMediaCollectionEnumerator
_batchSize = batchSize
};
// random start points must be applied on every build, not just the first
var random = new Random(state.Seed);
// collections should share enumerators
var enumeratorMap = new Dictionary<CollectionKey, IMediaCollectionEnumerator>();
result._allMediaItemIds = [];
@ -166,6 +175,11 @@ public class PlaylistEnumerator : IMediaCollectionEnumerator @@ -166,6 +175,11 @@ public class PlaylistEnumerator : IMediaCollectionEnumerator
switch (playlistItem.PlaybackOrder)
{
case PlaybackOrder.Chronological:
if (randomStartPoint)
{
initState.Index = items.Count > 0 ? random.Next(0, items.Count) : 0;
}
enumerator = new ChronologicalMediaCollectionEnumerator(items, initState);
break;
// TODO: fix multi episode shuffle?
@ -187,18 +201,22 @@ public class PlaylistEnumerator : IMediaCollectionEnumerator @@ -187,18 +201,22 @@ public class PlaylistEnumerator : IMediaCollectionEnumerator
CollectionKey.ForPlaylistItem(playlistItem),
cancellationToken),
initState,
// TODO: fix this
false,
randomStartPoint,
cancellationToken);
break;
case PlaybackOrder.SeasonEpisode:
// TODO: check random start point?
if (randomStartPoint)
{
initState.Index = items.Count > 0 ? random.Next(0, items.Count) : 0;
}
enumerator = new SeasonEpisodeMediaCollectionEnumerator(items, initState);
// season, episode will filter out season 0, so we may get an empty enumerator back
if (enumerator.Count == 0)
{
enumerator = null;
}
break;
case PlaybackOrder.Random:
enumerator = new RandomizedMediaCollectionEnumerator(items, initState);
@ -229,7 +247,7 @@ public class PlaylistEnumerator : IMediaCollectionEnumerator @@ -229,7 +247,7 @@ public class PlaylistEnumerator : IMediaCollectionEnumerator
result._sortedEnumerators = result.ShufflePlaylistItems();
}
result.State = new CollectionEnumeratorState { Seed = state.Seed };
result.State = new CollectionEnumeratorState { Seed = state.Seed, Started = state.Started };
result.EnumeratorIndex = 0;
// this was a bug when playlist enumerators were first added; shouldn't happen anymore

27
ErsatzTV.Core/Scheduling/PlayoutBuilder.cs

@ -276,7 +276,6 @@ public class PlayoutBuilder : IPlayoutBuilder @@ -276,7 +276,6 @@ public class PlayoutBuilder : IPlayoutBuilder
parameters.Start,
parameters.Finish,
parameters.CollectionMediaItems,
false,
cancellationToken);
}
@ -314,7 +313,6 @@ public class PlayoutBuilder : IPlayoutBuilder @@ -314,7 +313,6 @@ public class PlayoutBuilder : IPlayoutBuilder
parameters.Start,
parameters.Finish,
parameters.CollectionMediaItems,
true,
cancellationToken);
foreach (BaseError error in maybeResult.LeftToSeq())
@ -362,7 +360,6 @@ public class PlayoutBuilder : IPlayoutBuilder @@ -362,7 +360,6 @@ public class PlayoutBuilder : IPlayoutBuilder
parameters.Start,
parameters.Finish,
parameters.CollectionMediaItems,
false,
cancellationToken);
}
@ -437,7 +434,6 @@ public class PlayoutBuilder : IPlayoutBuilder @@ -437,7 +434,6 @@ public class PlayoutBuilder : IPlayoutBuilder
DateTimeOffset playoutStart,
DateTimeOffset playoutFinish,
Map<CollectionKey, List<MediaItem>> collectionMediaItems,
bool randomStartPoint,
CancellationToken cancellationToken)
{
DateTimeOffset trimBefore = playoutStart.AddHours(-4);
@ -476,7 +472,6 @@ public class PlayoutBuilder : IPlayoutBuilder @@ -476,7 +472,6 @@ public class PlayoutBuilder : IPlayoutBuilder
finish,
collectionMediaItems,
true,
randomStartPoint,
cancellationToken);
foreach (BaseError error in buildResult.LeftToSeq())
@ -489,9 +484,6 @@ public class PlayoutBuilder : IPlayoutBuilder @@ -489,9 +484,6 @@ public class PlayoutBuilder : IPlayoutBuilder
result = r;
}
// only randomize once (at the start of the playout)
randomStartPoint = false;
start = playout.Anchor?.NextStartOffset ?? start;
finish = finish.AddDays(1);
}
@ -513,7 +505,6 @@ public class PlayoutBuilder : IPlayoutBuilder @@ -513,7 +505,6 @@ public class PlayoutBuilder : IPlayoutBuilder
playoutFinish,
collectionMediaItems,
false,
randomStartPoint,
cancellationToken);
foreach (BaseError error in buildResult.LeftToSeq())
@ -565,7 +556,6 @@ public class PlayoutBuilder : IPlayoutBuilder @@ -565,7 +556,6 @@ public class PlayoutBuilder : IPlayoutBuilder
DateTimeOffset playoutFinish,
Map<CollectionKey, List<MediaItem>> collectionMediaItems,
bool saveAnchorDate,
bool randomStartPoint,
CancellationToken cancellationToken)
{
var random = new Random(playout.Seed);
@ -589,9 +579,6 @@ public class PlayoutBuilder : IPlayoutBuilder @@ -589,9 +579,6 @@ public class PlayoutBuilder : IPlayoutBuilder
// _logger.LogDebug("Active schedule is: {Schedule}", activeSchedule.Name);
// random start points are disabled in some scenarios, so ensure it's enabled and active
randomStartPoint = randomStartPoint && activeSchedule.RandomStartPoint;
var sortedScheduleItems = activeSchedule.Items.OrderBy(i => i.Index).ToList();
CollectionEnumeratorState scheduleItemsEnumeratorState =
playout.Anchor?.ScheduleItemsEnumeratorState ?? new CollectionEnumeratorState
@ -619,7 +606,7 @@ public class PlayoutBuilder : IPlayoutBuilder @@ -619,7 +606,7 @@ public class PlayoutBuilder : IPlayoutBuilder
scheduleItem.MarathonShuffleGroups,
scheduleItem.MarathonShuffleItems,
scheduleItem.MarathonBatchSize,
randomStartPoint,
activeSchedule.RandomStartPoint,
random,
cancellationToken);
@ -640,7 +627,7 @@ public class PlayoutBuilder : IPlayoutBuilder @@ -640,7 +627,7 @@ public class PlayoutBuilder : IPlayoutBuilder
marathonShuffleGroups: false,
marathonShuffleItems: false,
marathonBatchSize: null,
randomStartPoint,
activeSchedule.RandomStartPoint,
random,
cancellationToken);
@ -718,7 +705,7 @@ public class PlayoutBuilder : IPlayoutBuilder @@ -718,7 +705,7 @@ public class PlayoutBuilder : IPlayoutBuilder
scheduleItem.MarathonShuffleGroups,
scheduleItem.MarathonShuffleItems,
scheduleItem.MarathonBatchSize,
randomStartPoint,
activeSchedule.RandomStartPoint,
random,
cancellationToken);
@ -1325,6 +1312,7 @@ public class PlayoutBuilder : IPlayoutBuilder @@ -1325,6 +1312,7 @@ public class PlayoutBuilder : IPlayoutBuilder
state,
marathonShuffleGroups,
batchSize: Option<int>.None,
randomStartPoint,
cancellationToken);
}
}
@ -1346,8 +1334,7 @@ public class PlayoutBuilder : IPlayoutBuilder @@ -1346,8 +1334,7 @@ public class PlayoutBuilder : IPlayoutBuilder
}
}
// index shouldn't ever be greater than zero with randomStartPoint since anchors shouldn't exist, but
randomStartPoint = randomStartPoint && state.Index == 0;
randomStartPoint = randomStartPoint && !state.Started;
switch (playbackOrder)
{
@ -1357,7 +1344,7 @@ public class PlayoutBuilder : IPlayoutBuilder @@ -1357,7 +1344,7 @@ public class PlayoutBuilder : IPlayoutBuilder
state = new CollectionEnumeratorState
{
Seed = state.Seed,
Index = random.Next(0, mediaItems.Count - 1)
Index = mediaItems.Count > 0 ? random.Next(0, mediaItems.Count) : 0
};
}
@ -1368,7 +1355,7 @@ public class PlayoutBuilder : IPlayoutBuilder @@ -1368,7 +1355,7 @@ public class PlayoutBuilder : IPlayoutBuilder
state = new CollectionEnumeratorState
{
Seed = state.Seed,
Index = random.Next(0, mediaItems.Count - 1)
Index = mediaItems.Count > 0 ? random.Next(0, mediaItems.Count) : 0
};
}

8
ErsatzTV.Core/Scheduling/RandomizedMediaCollectionEnumerator.cs

@ -21,7 +21,7 @@ public class RandomizedMediaCollectionEnumerator : IMediaCollectionEnumerator @@ -21,7 +21,7 @@ public class RandomizedMediaCollectionEnumerator : IMediaCollectionEnumerator
_mediaItems.Bind(i => i.GetNonZeroDuration()).OrderBy(identity).HeadOrNone());
_random = new Random(state.Seed);
State = new CollectionEnumeratorState { Seed = state.Seed };
State = new CollectionEnumeratorState { Seed = state.Seed, Started = state.Started };
// we want to move at least once so we start with a random item and not the first
// because _index defaults to 0
if (State.Index == state.Index)
@ -37,9 +37,12 @@ public class RandomizedMediaCollectionEnumerator : IMediaCollectionEnumerator @@ -37,9 +37,12 @@ public class RandomizedMediaCollectionEnumerator : IMediaCollectionEnumerator
}
}
public void ResetState(CollectionEnumeratorState state) =>
public void ResetState(CollectionEnumeratorState state)
{
// seed never changes here, no need to reset
State.Index = state.Index;
State.Started = state.Started;
}
public string SchedulingContextName => "Random";
@ -57,6 +60,7 @@ public class RandomizedMediaCollectionEnumerator : IMediaCollectionEnumerator @@ -57,6 +60,7 @@ public class RandomizedMediaCollectionEnumerator : IMediaCollectionEnumerator
_index = _random.Next() % _mediaItems.Count;
State.Index++;
State.Started = true;
}
public Option<TimeSpan> MinimumDuration => _lazyMinimumDuration.Value;

8
ErsatzTV.Core/Scheduling/RandomizedRotatingMediaCollectionEnumerator.cs

@ -45,7 +45,7 @@ public class RandomizedRotatingMediaCollectionEnumerator : IMediaCollectionEnume @@ -45,7 +45,7 @@ public class RandomizedRotatingMediaCollectionEnumerator : IMediaCollectionEnume
_groupNumber = 0;
State = new CollectionEnumeratorState { Seed = state.Seed };
State = new CollectionEnumeratorState { Seed = state.Seed, Started = state.Started };
// we want to move at least once so we start with a random item and not the first
// because _index defaults to 0
if (State.Index == state.Index)
@ -61,9 +61,12 @@ public class RandomizedRotatingMediaCollectionEnumerator : IMediaCollectionEnume @@ -61,9 +61,12 @@ public class RandomizedRotatingMediaCollectionEnumerator : IMediaCollectionEnume
}
}
public void ResetState(CollectionEnumeratorState state) =>
public void ResetState(CollectionEnumeratorState state)
{
// seed never changes here, no need to reset
State.Index = state.Index;
State.Started = state.Started;
}
public string SchedulingContextName => "Random Rotating";
@ -102,6 +105,7 @@ public class RandomizedRotatingMediaCollectionEnumerator : IMediaCollectionEnume @@ -102,6 +105,7 @@ public class RandomizedRotatingMediaCollectionEnumerator : IMediaCollectionEnume
int itemNumber = nextRandom % _groupMedia[groups[_groupNumber]].Count;
_index = _groupMedia[groups[_groupNumber]][itemNumber];
State.Index++;
State.Started = true;
}
public Option<TimeSpan> MinimumDuration => _lazyMinimumDuration.Value;

8
ErsatzTV.Core/Scheduling/SeasonEpisodeMediaCollectionEnumerator.cs

@ -21,7 +21,7 @@ public sealed class SeasonEpisodeMediaCollectionEnumerator : IMediaCollectionEnu @@ -21,7 +21,7 @@ public sealed class SeasonEpisodeMediaCollectionEnumerator : IMediaCollectionEnu
_lazyMinimumDuration = new Lazy<Option<TimeSpan>>(() =>
_sortedMediaItems.Bind(i => i.GetNonZeroDuration()).OrderBy(identity).HeadOrNone());
State = new CollectionEnumeratorState { Seed = state.Seed };
State = new CollectionEnumeratorState { Seed = state.Seed, Started = state.Started };
if (state.Index >= _sortedMediaItems.Count)
{
@ -35,9 +35,12 @@ public sealed class SeasonEpisodeMediaCollectionEnumerator : IMediaCollectionEnu @@ -35,9 +35,12 @@ public sealed class SeasonEpisodeMediaCollectionEnumerator : IMediaCollectionEnu
}
}
public void ResetState(CollectionEnumeratorState state) =>
public void ResetState(CollectionEnumeratorState state)
{
// seed doesn't matter here
State.Index = state.Index;
State.Started = state.Started;
}
public string SchedulingContextName => "Season, Episode";
@ -54,6 +57,7 @@ public sealed class SeasonEpisodeMediaCollectionEnumerator : IMediaCollectionEnu @@ -54,6 +57,7 @@ public sealed class SeasonEpisodeMediaCollectionEnumerator : IMediaCollectionEnu
}
State.Index = (State.Index + 1) % _sortedMediaItems.Count;
State.Started = true;
}
public Option<TimeSpan> MinimumDuration => _lazyMinimumDuration.Value;

5
ErsatzTV.Core/Scheduling/ShuffleInOrderCollectionEnumerator.cs

@ -39,7 +39,7 @@ public class ShuffleInOrderCollectionEnumerator : IMediaCollectionEnumerator @@ -39,7 +39,7 @@ public class ShuffleInOrderCollectionEnumerator : IMediaCollectionEnumerator
new Lazy<Option<TimeSpan>>(() =>
_shuffled.Bind(i => i.GetNonZeroDuration()).OrderBy(identity).HeadOrNone());
State = new CollectionEnumeratorState { Seed = state.Seed };
State = new CollectionEnumeratorState { Seed = state.Seed, Started = state.Started };
while (State.Index < state.Index)
{
MoveNext(Option<DateTimeOffset>.None);
@ -57,6 +57,7 @@ public class ShuffleInOrderCollectionEnumerator : IMediaCollectionEnumerator @@ -57,6 +57,7 @@ public class ShuffleInOrderCollectionEnumerator : IMediaCollectionEnumerator
State.Seed = state.Seed;
State.Index = state.Index;
State.Started = state.Started;
}
public string SchedulingContextName => "Shuffle in Order";
@ -95,6 +96,8 @@ public class ShuffleInOrderCollectionEnumerator : IMediaCollectionEnumerator @@ -95,6 +96,8 @@ public class ShuffleInOrderCollectionEnumerator : IMediaCollectionEnumerator
{
State.Index %= _shuffled.Length;
}
State.Started = true;
}
public Option<TimeSpan> MinimumDuration => _lazyMinimumDuration.Value;

5
ErsatzTV.Core/Scheduling/ShuffledMediaCollectionEnumerator.cs

@ -36,7 +36,7 @@ public class ShuffledMediaCollectionEnumerator : IMediaCollectionEnumerator @@ -36,7 +36,7 @@ public class ShuffledMediaCollectionEnumerator : IMediaCollectionEnumerator
new Lazy<Option<TimeSpan>>(() =>
_shuffled.Bind(i => i.GetNonZeroDuration()).OrderBy(identity).HeadOrNone());
State = new CollectionEnumeratorState { Seed = state.Seed };
State = new CollectionEnumeratorState { Seed = state.Seed, Started = state.Started };
while (State.Index < state.Index)
{
MoveNext(Option<DateTimeOffset>.None);
@ -54,6 +54,7 @@ public class ShuffledMediaCollectionEnumerator : IMediaCollectionEnumerator @@ -54,6 +54,7 @@ public class ShuffledMediaCollectionEnumerator : IMediaCollectionEnumerator
State.Seed = state.Seed;
State.Index = state.Index;
State.Started = state.Started;
}
public string SchedulingContextName => "Shuffle";
@ -92,6 +93,8 @@ public class ShuffledMediaCollectionEnumerator : IMediaCollectionEnumerator @@ -92,6 +93,8 @@ public class ShuffledMediaCollectionEnumerator : IMediaCollectionEnumerator
{
State.Index %= _mediaItemCount;
}
State.Started = true;
}
public Option<TimeSpan> MinimumDuration => _lazyMinimumDuration.Value;

3
ErsatzTV.Core/Scheduling/ShuffledScheduleItemsEnumerator.cs

@ -26,7 +26,7 @@ public class ShuffledScheduleItemsEnumerator : IScheduleItemsEnumerator @@ -26,7 +26,7 @@ public class ShuffledScheduleItemsEnumerator : IScheduleItemsEnumerator
_random = new CloneableRandom(state.Seed);
_shuffled = Shuffle(_scheduleItems, _random);
State = new CollectionEnumeratorState { Seed = state.Seed };
State = new CollectionEnumeratorState { Seed = state.Seed, Started = state.Started };
while (State.Index < state.Index)
{
MoveNext();
@ -57,6 +57,7 @@ public class ShuffledScheduleItemsEnumerator : IScheduleItemsEnumerator @@ -57,6 +57,7 @@ public class ShuffledScheduleItemsEnumerator : IScheduleItemsEnumerator
}
State.Index %= _scheduleItemsCount;
State.Started = true;
}
public ProgramScheduleItem Peek(int offset)

1
ErsatzTV.Core/Scheduling/YamlScheduling/EnumeratorCache.cs

@ -175,6 +175,7 @@ public class EnumeratorCache(IMediaCollectionRepository mediaCollectionRepositor @@ -175,6 +175,7 @@ public class EnumeratorCache(IMediaCollectionRepository mediaCollectionRepositor
state,
shufflePlaylistItems: false,
batchSize: Option<int>.None,
randomStartPoint: false,
cancellationToken);
}

8
ErsatzTV.FFmpeg.Tests/ErsatzTV.FFmpeg.Tests.csproj

@ -8,10 +8,10 @@ @@ -8,10 +8,10 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.DependencyModel" Version="10.0.9" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.9" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.7.0" />
<PackageReference Include="NSubstitute" Version="5.3.0" />
<PackageReference Include="Microsoft.Extensions.DependencyModel" Version="10.0.10" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.10" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.8.1" />
<PackageReference Include="NSubstitute" Version="6.0.0" />
<PackageReference Include="NUnit" Version="4.6.1" />
<PackageReference Include="NUnit3TestAdapter" Version="6.2.0" />
<PackageReference Include="Shouldly" Version="4.3.0" />

4
ErsatzTV.FFmpeg/ErsatzTV.FFmpeg.csproj

@ -15,8 +15,8 @@ @@ -15,8 +15,8 @@
<PackageReference Include="Hardware.Info" Version="110.0.0.1" />
<PackageReference Include="LanguageExt.Core" Version="4.4.9" />
<PackageReference Include="Lennox.NvEncSharp" Version="2.1.1" />
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="10.0.9" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.9" />
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="10.0.10" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.10" />
</ItemGroup>

7060
ErsatzTV.Infrastructure.MySql/Migrations/20260722002414_Add_CollectionEnumeratorState_Started.Designer.cs generated

File diff suppressed because it is too large Load Diff

57
ErsatzTV.Infrastructure.MySql/Migrations/20260722002414_Add_CollectionEnumeratorState_Started.cs

@ -0,0 +1,57 @@ @@ -0,0 +1,57 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ErsatzTV.Infrastructure.MySql.Migrations
{
/// <inheritdoc />
public partial class Add_CollectionEnumeratorState_Started : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "Started",
table: "ScheduleItemsEnumeratorState",
type: "tinyint(1)",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<bool>(
name: "Started",
table: "FillGroupEnumeratorState",
type: "tinyint(1)",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<bool>(
name: "Started",
table: "CollectionEnumeratorState",
type: "tinyint(1)",
nullable: false,
defaultValue: false);
// treat all existing enumerators as started so upgrades never yank an in-progress
// enumerator to a random start point; only enumerators created after this get a random start
migrationBuilder.Sql("UPDATE `ScheduleItemsEnumeratorState` SET `Started` = 1");
migrationBuilder.Sql("UPDATE `FillGroupEnumeratorState` SET `Started` = 1");
migrationBuilder.Sql("UPDATE `CollectionEnumeratorState` SET `Started` = 1");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "Started",
table: "ScheduleItemsEnumeratorState");
migrationBuilder.DropColumn(
name: "Started",
table: "FillGroupEnumeratorState");
migrationBuilder.DropColumn(
name: "Started",
table: "CollectionEnumeratorState");
}
}
}

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

@ -5207,6 +5207,9 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations @@ -5207,6 +5207,9 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
b2.Property<int>("Seed")
.HasColumnType("int");
b2.Property<bool>("Started")
.HasColumnType("tinyint(1)");
b2.HasKey("PlayoutAnchorPlayoutId");
b2.ToTable("ScheduleItemsEnumeratorState", (string)null);
@ -5355,6 +5358,9 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations @@ -5355,6 +5358,9 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
b1.Property<int>("Seed")
.HasColumnType("int");
b1.Property<bool>("Started")
.HasColumnType("tinyint(1)");
b1.HasKey("PlayoutProgramScheduleAnchorId");
b1.ToTable("CollectionEnumeratorState", (string)null);
@ -5405,6 +5411,9 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations @@ -5405,6 +5411,9 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
b1.Property<int>("Seed")
.HasColumnType("int");
b1.Property<bool>("Started")
.HasColumnType("tinyint(1)");
b1.HasKey("PlayoutScheduleItemFillGroupIndexId");
b1.ToTable("FillGroupEnumeratorState", (string)null);

2
ErsatzTV.Infrastructure.Sqlite/ErsatzTV.Infrastructure.Sqlite.csproj

@ -17,7 +17,7 @@ @@ -17,7 +17,7 @@
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="9.0.15" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="9.0.15" />
<!-- Force fixed native SQLite to resolve NU1903 (GHSA-2m69-gcr7-jv3q) from the transitive 2.1.10 -->
<PackageReference Include="SQLitePCLRaw.bundle_e_sqlite3" Version="3.0.3" />
<PackageReference Include="SQLitePCLRaw.bundle_e_sqlite3" Version="3.0.4" />
</ItemGroup>

6887
ErsatzTV.Infrastructure.Sqlite/Migrations/20260722002327_Add_CollectionEnumeratorState_Started.Designer.cs generated

File diff suppressed because it is too large Load Diff

57
ErsatzTV.Infrastructure.Sqlite/Migrations/20260722002327_Add_CollectionEnumeratorState_Started.cs

@ -0,0 +1,57 @@ @@ -0,0 +1,57 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ErsatzTV.Infrastructure.Sqlite.Migrations
{
/// <inheritdoc />
public partial class Add_CollectionEnumeratorState_Started : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "Started",
table: "ScheduleItemsEnumeratorState",
type: "INTEGER",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<bool>(
name: "Started",
table: "FillGroupEnumeratorState",
type: "INTEGER",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<bool>(
name: "Started",
table: "CollectionEnumeratorState",
type: "INTEGER",
nullable: false,
defaultValue: false);
// treat all existing enumerators as started so upgrades never yank an in-progress
// enumerator to a random start point; only enumerators created after this get a random start
migrationBuilder.Sql("""UPDATE "ScheduleItemsEnumeratorState" SET "Started" = 1""");
migrationBuilder.Sql("""UPDATE "FillGroupEnumeratorState" SET "Started" = 1""");
migrationBuilder.Sql("""UPDATE "CollectionEnumeratorState" SET "Started" = 1""");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "Started",
table: "ScheduleItemsEnumeratorState");
migrationBuilder.DropColumn(
name: "Started",
table: "FillGroupEnumeratorState");
migrationBuilder.DropColumn(
name: "Started",
table: "CollectionEnumeratorState");
}
}
}

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

@ -5034,6 +5034,9 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations @@ -5034,6 +5034,9 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
b2.Property<int>("Seed")
.HasColumnType("INTEGER");
b2.Property<bool>("Started")
.HasColumnType("INTEGER");
b2.HasKey("PlayoutAnchorPlayoutId");
b2.ToTable("ScheduleItemsEnumeratorState", (string)null);
@ -5182,6 +5185,9 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations @@ -5182,6 +5185,9 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
b1.Property<int>("Seed")
.HasColumnType("INTEGER");
b1.Property<bool>("Started")
.HasColumnType("INTEGER");
b1.HasKey("PlayoutProgramScheduleAnchorId");
b1.ToTable("CollectionEnumeratorState", (string)null);
@ -5232,6 +5238,9 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations @@ -5232,6 +5238,9 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
b1.Property<int>("Seed")
.HasColumnType("INTEGER");
b1.Property<bool>("Started")
.HasColumnType("INTEGER");
b1.HasKey("PlayoutScheduleItemFillGroupIndexId");
b1.ToTable("FillGroupEnumeratorState", (string)null);

6
ErsatzTV.Infrastructure.Tests/ErsatzTV.Infrastructure.Tests.csproj

@ -9,8 +9,8 @@ @@ -9,8 +9,8 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.7.0" />
<PackageReference Include="NSubstitute" Version="5.3.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.8.1" />
<PackageReference Include="NSubstitute" Version="6.0.0" />
<PackageReference Include="NUnit" Version="4.6.1" />
<PackageReference Include="NUnit3TestAdapter" Version="6.2.0" />
<PackageReference Include="NUnit.Analyzers" Version="4.14.0">
@ -22,7 +22,7 @@ @@ -22,7 +22,7 @@
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Shouldly" Version="4.3.0" />
<PackageReference Include="Testably.Abstractions.Testing" Version="6.6.0" />
<PackageReference Include="Testably.Abstractions.Testing" Version="7.0.1" />
</ItemGroup>
<ItemGroup>

6
ErsatzTV.Infrastructure/Data/Repositories/MovieRepository.cs

@ -195,12 +195,12 @@ public class MovieRepository : IMovieRepository @@ -195,12 +195,12 @@ public class MovieRepository : IMovieRepository
.Map(result => result > 0);
}
public async Task<bool> UpdateSortTitle(MovieMetadata movieMetadata)
public async Task<bool> UpdateTitles(MovieMetadata movieMetadata, string title, string sortTitle)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
return await dbContext.Connection.ExecuteAsync(
@"UPDATE MovieMetadata SET SortTitle = @SortTitle WHERE Id = @Id",
new { movieMetadata.SortTitle, movieMetadata.Id }).Map(result => result > 0);
@"UPDATE MovieMetadata SET Title = @Title, SortTitle = @SortTitle WHERE Id = @Id",
new { Title = title, SortTitle = sortTitle, movieMetadata.Id }).Map(result => result > 0);
}
public async Task<bool> AddDirector(MovieMetadata metadata, Director director)

8
ErsatzTV.Infrastructure/Data/Repositories/OtherVideoRepository.cs

@ -175,6 +175,14 @@ public class OtherVideoRepository : IOtherVideoRepository @@ -175,6 +175,14 @@ public class OtherVideoRepository : IOtherVideoRepository
new { writer.Name, MetadataId = metadata.Id }).Map(result => result > 0);
}
public async Task<bool> UpdateTitles(OtherVideoMetadata metadata, string title, string sortTitle)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
return await dbContext.Connection.ExecuteAsync(
"UPDATE OtherVideoMetadata SET Title = @Title, SortTitle = @SortTitle WHERE Id = @Id",
new { Title = title, SortTitle = sortTitle, metadata.Id }).Map(result => result > 0);
}
private async Task<Either<BaseError, MediaItemScanResult<OtherVideo>>> AddOtherVideo(
TvContext dbContext,
int libraryPathId,

6
ErsatzTV.Infrastructure/ErsatzTV.Infrastructure.csproj

@ -18,8 +18,8 @@ @@ -18,8 +18,8 @@
<PackageReference Include="EFCore.BulkExtensions" Version="[9.0.2,10)" />
<PackageReference Include="Elastic.Clients.Elasticsearch" Version="9.4.2" />
<PackageReference Include="Humanizer.Core" Version="3.0.10" />
<PackageReference Include="Jint" Version="4.11.0" />
<PackageReference Include="JsonSchema.Net" Version="9.2.2" />
<PackageReference Include="Jint" Version="4.13.0" />
<PackageReference Include="JsonSchema.Net" Version="9.3.0" />
<PackageReference Include="Lucene.Net" Version="4.8.0-beta00018" />
<PackageReference Include="Lucene.Net.Analysis.Common" Version="4.8.0-beta00018" />
<PackageReference Include="Lucene.Net.QueryParser" Version="4.8.0-beta00018" />
@ -36,7 +36,7 @@ @@ -36,7 +36,7 @@
<PackageReference Include="RichTextKit.Stbear" Version="0.4.167.3" />
<PackageReference Include="Scriban.Signed" Version="7.2.5" />
<PackageReference Include="SixLabors.ImageSharp" Version="[3.1.12,4)" />
<PackageReference Include="SkiaSharp" Version="4.150.0" />
<PackageReference Include="SkiaSharp" Version="4.150.1" />
<PackageReference Include="TagLibSharp" Version="2.3.0" />
<PackageReference Include="TimeZoneConverter" Version="7.2.0" />
</ItemGroup>

5
ErsatzTV.Infrastructure/Plex/PlexEtag.cs

@ -27,6 +27,10 @@ public class PlexEtag @@ -27,6 +27,10 @@ public class PlexEtag
// video updated at
bw.Write(response.UpdatedAt);
// video title
bw.Write((byte)FieldKey.Title);
bw.Write(response.Title ?? string.Empty);
foreach (PlexMediaResponse<PlexPartResponse> media in response.Media)
{
// media id
@ -330,6 +334,7 @@ public class PlexEtag @@ -330,6 +334,7 @@ public class PlexEtag
Thumb = 20,
Art = 21,
Title = 22,
File = 30,
Key = 31,

3
ErsatzTV.Infrastructure/Scheduling/MultiEpisodeShuffleCollectionEnumerator.cs

@ -83,7 +83,7 @@ public class MultiEpisodeShuffleCollectionEnumerator : IMediaCollectionEnumerato @@ -83,7 +83,7 @@ public class MultiEpisodeShuffleCollectionEnumerator : IMediaCollectionEnumerato
new Lazy<Option<TimeSpan>>(() =>
_shuffled.Bind(i => i.GetNonZeroDuration()).OrderBy(identity).HeadOrNone());
State = new CollectionEnumeratorState { Seed = state.Seed };
State = new CollectionEnumeratorState { Seed = state.Seed, Started = state.Started };
while (State.Index < state.Index)
{
MoveNext(Option<DateTimeOffset>.None);
@ -100,6 +100,7 @@ public class MultiEpisodeShuffleCollectionEnumerator : IMediaCollectionEnumerato @@ -100,6 +100,7 @@ public class MultiEpisodeShuffleCollectionEnumerator : IMediaCollectionEnumerato
}
State.Index = state.Index;
State.Started = state.Started;
}
public string SchedulingContextName => "Multi-Episode Shuffle";

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

@ -348,13 +348,13 @@ public class TranscodingTests @@ -348,13 +348,13 @@ public class TranscodingTests
metadataRepository.When(x => x.UpdateStatistics(Arg.Any<MediaItem>(), Arg.Any<MediaVersion>(), Arg.Any<bool>()))
.Do(x =>
{
MediaVersion version = x.Arg<MediaVersion>();
if (version.Streams.Any(s => s.MediaStreamKind == MediaStreamKind.Video && !s.AttachedPic))
MediaVersion? version = x.Arg<MediaVersion>();
if (version != null && version.Streams.Any(s => s.MediaStreamKind == MediaStreamKind.Video && !s.AttachedPic))
{
version.MediaFiles = videoVersion.MediaFiles;
videoVersion = version;
}
else
else if (version != null)
{
version.MediaFiles = songVersion.MediaFiles;
songVersion = version;
@ -520,8 +520,11 @@ public class TranscodingTests @@ -520,8 +520,11 @@ public class TranscodingTests
.Do(args =>
{
MediaVersion? version = args.Arg<MediaVersion>();
if (version != null)
{
version.MediaFiles = v.MediaFiles;
v = version;
}
});
var localStatisticsProvider = new LocalStatisticsProvider(

64
ErsatzTV.Scanner.Tests/Core/Metadata/MovieFolderScannerTests.cs

@ -59,7 +59,7 @@ public class MovieFolderScannerTests @@ -59,7 +59,7 @@ public class MovieFolderScannerTests
_movieRepository = Substitute.For<IMovieRepository>();
_movieRepository.GetOrAdd(Arg.Any<LibraryPath>(), Arg.Any<LibraryFolder>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(args =>
Right<BaseError, MediaItemScanResult<Movie>>(new FakeMovieWithPath(args.Arg<string>()))
Right<BaseError, MediaItemScanResult<Movie>>(new FakeMovieWithPath(args.Arg<string>() ?? string.Empty))
.AsTask());
_movieRepository.FindMoviePaths(Arg.Any<LibraryPath>())
.Returns(new List<string>().AsEnumerable().AsTask());
@ -78,7 +78,7 @@ public class MovieFolderScannerTests @@ -78,7 +78,7 @@ public class MovieFolderScannerTests
_localMetadataProvider.RefreshFallbackMetadata(Arg.Any<Movie>())
.Returns(arg =>
{
((Movie)arg.Arg<MediaItem>()).MovieMetadata = [new MovieMetadata { Artwork = [] }];
(arg.Arg<MediaItem>() as Movie ?? new Movie()).MovieMetadata = [new MovieMetadata { Artwork = [] }];
return Task.FromResult(true);
});
@ -141,10 +141,10 @@ public class MovieFolderScannerTests @@ -141,10 +141,10 @@ public class MovieFolderScannerTests
await _localStatisticsProvider.Received(1).RefreshStatistics(
FFmpegPath,
FFprobePath,
Arg.Is<Movie>(i => i.MediaVersions.Head().MediaFiles.Head().Path == moviePath));
Arg.Is<Movie>(i => i != null && i.MediaVersions.Head().MediaFiles.Head().Path == moviePath));
await _localMetadataProvider.Received(1).RefreshFallbackMetadata(
Arg.Is<Movie>(i => i.MediaVersions.Head().MediaFiles.Head().Path == moviePath));
Arg.Is<Movie>(i => i != null && i.MediaVersions.Head().MediaFiles.Head().Path == moviePath));
}
[Test]
@ -190,10 +190,10 @@ public class MovieFolderScannerTests @@ -190,10 +190,10 @@ public class MovieFolderScannerTests
await _localStatisticsProvider.Received(1).RefreshStatistics(
FFmpegPath,
FFprobePath,
Arg.Is<Movie>(i => i.MediaVersions.Head().MediaFiles.Head().Path == moviePath));
Arg.Is<Movie>(i => i != null && i.MediaVersions.Head().MediaFiles.Head().Path == moviePath));
await _localMetadataProvider.Received(1).RefreshFallbackMetadata(
Arg.Is<Movie>(i => i.MediaVersions.Head().MediaFiles.Head().Path == moviePath));
Arg.Is<Movie>(i => i != null && i.MediaVersions.Head().MediaFiles.Head().Path == moviePath));
}
[Test]
@ -238,10 +238,10 @@ public class MovieFolderScannerTests @@ -238,10 +238,10 @@ public class MovieFolderScannerTests
await _localStatisticsProvider.Received(1).RefreshStatistics(
FFmpegPath,
FFprobePath,
Arg.Is<Movie>(i => i.MediaVersions.Head().MediaFiles.Head().Path == moviePath));
Arg.Is<Movie>(i => i != null && i.MediaVersions.Head().MediaFiles.Head().Path == moviePath));
await _localMetadataProvider.Received(1).RefreshSidecarMetadata(
Arg.Is<Movie>(i => i.MediaVersions.Head().MediaFiles.Head().Path == moviePath),
Arg.Is<Movie>(i => i != null && i.MediaVersions.Head().MediaFiles.Head().Path == moviePath),
metadataPath);
}
@ -287,10 +287,10 @@ public class MovieFolderScannerTests @@ -287,10 +287,10 @@ public class MovieFolderScannerTests
await _localStatisticsProvider.Received(1).RefreshStatistics(
FFmpegPath,
FFprobePath,
Arg.Is<Movie>(i => i.MediaVersions.Head().MediaFiles.Head().Path == moviePath));
Arg.Is<Movie>(i => i != null && i.MediaVersions.Head().MediaFiles.Head().Path == moviePath));
await _localMetadataProvider.Received(1).RefreshSidecarMetadata(
Arg.Is<Movie>(i => i.MediaVersions.Head().MediaFiles.Head().Path == moviePath),
Arg.Is<Movie>(i => i != null && i.MediaVersions.Head().MediaFiles.Head().Path == moviePath),
metadataPath);
}
@ -340,10 +340,10 @@ public class MovieFolderScannerTests @@ -340,10 +340,10 @@ public class MovieFolderScannerTests
await _localStatisticsProvider.Received(1).RefreshStatistics(
FFmpegPath,
FFprobePath,
Arg.Is<Movie>(i => i.MediaVersions.Head().MediaFiles.Head().Path == moviePath));
Arg.Is<Movie>(i => i != null && i.MediaVersions.Head().MediaFiles.Head().Path == moviePath));
await _localMetadataProvider.Received(1).RefreshFallbackMetadata(
Arg.Is<Movie>(i => i.MediaVersions.Head().MediaFiles.Head().Path == moviePath));
Arg.Is<Movie>(i => i != null && i.MediaVersions.Head().MediaFiles.Head().Path == moviePath));
await _imageCache.Received(1).CopyArtworkToCache(posterPath, ArtworkKind.Poster);
}
@ -394,10 +394,10 @@ public class MovieFolderScannerTests @@ -394,10 +394,10 @@ public class MovieFolderScannerTests
await _localStatisticsProvider.Received(1).RefreshStatistics(
FFmpegPath,
FFprobePath,
Arg.Is<Movie>(i => i.MediaVersions.Head().MediaFiles.Head().Path == moviePath));
Arg.Is<Movie>(i => i != null && i.MediaVersions.Head().MediaFiles.Head().Path == moviePath));
await _localMetadataProvider.Received(1).RefreshFallbackMetadata(
Arg.Is<Movie>(i => i.MediaVersions.Head().MediaFiles.Head().Path == moviePath));
Arg.Is<Movie>(i => i != null && i.MediaVersions.Head().MediaFiles.Head().Path == moviePath));
await _imageCache.Received(1).CopyArtworkToCache(posterPath, ArtworkKind.Poster);
}
@ -448,10 +448,10 @@ public class MovieFolderScannerTests @@ -448,10 +448,10 @@ public class MovieFolderScannerTests
await _localStatisticsProvider.Received(1).RefreshStatistics(
FFmpegPath,
FFprobePath,
Arg.Is<Movie>(i => i.MediaVersions.Head().MediaFiles.Head().Path == moviePath));
Arg.Is<Movie>(i => i != null && i.MediaVersions.Head().MediaFiles.Head().Path == moviePath));
await _localMetadataProvider.Received(1).RefreshFallbackMetadata(
Arg.Is<Movie>(i => i.MediaVersions.Head().MediaFiles.Head().Path == moviePath));
Arg.Is<Movie>(i => i != null && i.MediaVersions.Head().MediaFiles.Head().Path == moviePath));
await _imageCache.Received(1).CopyArtworkToCache(posterPath, ArtworkKind.Poster);
}
@ -502,10 +502,10 @@ public class MovieFolderScannerTests @@ -502,10 +502,10 @@ public class MovieFolderScannerTests
await _localStatisticsProvider.Received(1).RefreshStatistics(
FFmpegPath,
FFprobePath,
Arg.Is<Movie>(i => i.MediaVersions.Head().MediaFiles.Head().Path == moviePath));
Arg.Is<Movie>(i => i != null && i.MediaVersions.Head().MediaFiles.Head().Path == moviePath));
await _localMetadataProvider.Received(1).RefreshFallbackMetadata(
Arg.Is<Movie>(i => i.MediaVersions.Head().MediaFiles.Head().Path == moviePath));
Arg.Is<Movie>(i => i != null && i.MediaVersions.Head().MediaFiles.Head().Path == moviePath));
await _imageCache.Received(1).CopyArtworkToCache(fanArtPath, ArtworkKind.FanArt);
}
@ -556,10 +556,10 @@ public class MovieFolderScannerTests @@ -556,10 +556,10 @@ public class MovieFolderScannerTests
await _localStatisticsProvider.Received(1).RefreshStatistics(
FFmpegPath,
FFprobePath,
Arg.Is<Movie>(i => i.MediaVersions.Head().MediaFiles.Head().Path == moviePath));
Arg.Is<Movie>(i => i != null && i.MediaVersions.Head().MediaFiles.Head().Path == moviePath));
await _localMetadataProvider.Received(1).RefreshFallbackMetadata(
Arg.Is<Movie>(i => i.MediaVersions.Head().MediaFiles.Head().Path == moviePath));
Arg.Is<Movie>(i => i != null && i.MediaVersions.Head().MediaFiles.Head().Path == moviePath));
await _imageCache.Received(1).CopyArtworkToCache(fanArtPath, ArtworkKind.FanArt);
}
@ -610,10 +610,10 @@ public class MovieFolderScannerTests @@ -610,10 +610,10 @@ public class MovieFolderScannerTests
await _localStatisticsProvider.Received(1).RefreshStatistics(
FFmpegPath,
FFprobePath,
Arg.Is<Movie>(i => i.MediaVersions.Head().MediaFiles.Head().Path == moviePath));
Arg.Is<Movie>(i => i != null && i.MediaVersions.Head().MediaFiles.Head().Path == moviePath));
await _localMetadataProvider.Received(1).RefreshFallbackMetadata(
Arg.Is<Movie>(i => i.MediaVersions.Head().MediaFiles.Head().Path == moviePath));
Arg.Is<Movie>(i => i != null && i.MediaVersions.Head().MediaFiles.Head().Path == moviePath));
await _imageCache.Received(1).CopyArtworkToCache(fanArtPath, ArtworkKind.FanArt);
}
@ -664,10 +664,10 @@ public class MovieFolderScannerTests @@ -664,10 +664,10 @@ public class MovieFolderScannerTests
await _localStatisticsProvider.Received(1).RefreshStatistics(
FFmpegPath,
FFprobePath,
Arg.Is<Movie>(i => i.MediaVersions.Head().MediaFiles.Head().Path == moviePath));
Arg.Is<Movie>(i => i != null && i.MediaVersions.Head().MediaFiles.Head().Path == moviePath));
await _localMetadataProvider.Received(1).RefreshFallbackMetadata(
Arg.Is<Movie>(i => i.MediaVersions.Head().MediaFiles.Head().Path == moviePath));
Arg.Is<Movie>(i => i != null && i.MediaVersions.Head().MediaFiles.Head().Path == moviePath));
// Should receive Poster call (as folder.ext is valid for poster)
await _imageCache.Received(1).CopyArtworkToCache(folderImagePath, ArtworkKind.Poster);
@ -805,10 +805,10 @@ public class MovieFolderScannerTests @@ -805,10 +805,10 @@ public class MovieFolderScannerTests
await _localStatisticsProvider.Received(1).RefreshStatistics(
FFmpegPath,
FFprobePath,
Arg.Is<Movie>(i => i.MediaVersions.Head().MediaFiles.Head().Path == moviePath));
Arg.Is<Movie>(i => i != null && i.MediaVersions.Head().MediaFiles.Head().Path == moviePath));
await _localMetadataProvider.Received(1).RefreshFallbackMetadata(
Arg.Is<Movie>(i => i.MediaVersions.Head().MediaFiles.Head().Path == moviePath));
Arg.Is<Movie>(i => i != null && i.MediaVersions.Head().MediaFiles.Head().Path == moviePath));
}
[Test]
@ -854,10 +854,10 @@ public class MovieFolderScannerTests @@ -854,10 +854,10 @@ public class MovieFolderScannerTests
await _localStatisticsProvider.Received(1).RefreshStatistics(
FFmpegPath,
FFprobePath,
Arg.Is<Movie>(i => i.MediaVersions.Head().MediaFiles.Head().Path == moviePath));
Arg.Is<Movie>(i => i != null && i.MediaVersions.Head().MediaFiles.Head().Path == moviePath));
await _localMetadataProvider.Received(1).RefreshFallbackMetadata(
Arg.Is<Movie>(i => i.MediaVersions.Head().MediaFiles.Head().Path == moviePath));
Arg.Is<Movie>(i => i != null && i.MediaVersions.Head().MediaFiles.Head().Path == moviePath));
}
[Test]
@ -905,10 +905,10 @@ public class MovieFolderScannerTests @@ -905,10 +905,10 @@ public class MovieFolderScannerTests
await _localStatisticsProvider.Received(1).RefreshStatistics(
FFmpegPath,
FFprobePath,
Arg.Is<Movie>(i => i.MediaVersions.Head().MediaFiles.Head().Path == moviePath));
Arg.Is<Movie>(i => i != null && i.MediaVersions.Head().MediaFiles.Head().Path == moviePath));
await _localMetadataProvider.Received(1).RefreshFallbackMetadata(
Arg.Is<Movie>(i => i.MediaVersions.Head().MediaFiles.Head().Path == moviePath));
Arg.Is<Movie>(i => i != null && i.MediaVersions.Head().MediaFiles.Head().Path == moviePath));
}
[Test]
@ -950,10 +950,10 @@ public class MovieFolderScannerTests @@ -950,10 +950,10 @@ public class MovieFolderScannerTests
await _localStatisticsProvider.Received(1).RefreshStatistics(
FFmpegPath,
FFprobePath,
Arg.Is<Movie>(i => i.MediaVersions.Head().MediaFiles.Head().Path == moviePath));
Arg.Is<Movie>(i => i != null && i.MediaVersions.Head().MediaFiles.Head().Path == moviePath));
await _localMetadataProvider.Received(1).RefreshFallbackMetadata(
Arg.Is<Movie>(i => i.MediaVersions.Head().MediaFiles.Head().Path == moviePath));
Arg.Is<Movie>(i => i != null && i.MediaVersions.Head().MediaFiles.Head().Path == moviePath));
}
[Test]

6
ErsatzTV.Scanner.Tests/ErsatzTV.Scanner.Tests.csproj

@ -10,8 +10,8 @@ @@ -10,8 +10,8 @@
<ItemGroup>
<PackageReference Include="LanguageExt.Core" Version="4.4.9" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.7.0" />
<PackageReference Include="NSubstitute" Version="5.3.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.8.1" />
<PackageReference Include="NSubstitute" Version="6.0.0" />
<PackageReference Include="NUnit" Version="4.6.1" />
<PackageReference Include="NUnit3TestAdapter" Version="6.2.0" />
<PackageReference Include="NUnit.Analyzers" Version="4.14.0">
@ -23,7 +23,7 @@ @@ -23,7 +23,7 @@
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Shouldly" Version="4.3.0" />
<PackageReference Include="Testably.Abstractions.Testing" Version="6.6.0" />
<PackageReference Include="Testably.Abstractions.Testing" Version="7.0.1" />
</ItemGroup>
<ItemGroup>

5
ErsatzTV.Scanner/Core/Plex/PlexMovieLibraryScanner.cs

@ -350,10 +350,11 @@ public class PlexMovieLibraryScanner : @@ -350,10 +350,11 @@ public class PlexMovieLibraryScanner :
result.IsUpdated = true;
}
if (fullMetadata.SortTitle != existingMetadata.SortTitle)
if (fullMetadata.SortTitle != existingMetadata.SortTitle || fullMetadata.Title != existingMetadata.Title)
{
existingMetadata.Title = fullMetadata.Title;
existingMetadata.SortTitle = fullMetadata.SortTitle;
if (await _movieRepository.UpdateSortTitle(existingMetadata))
if (await _movieRepository.UpdateTitles(existingMetadata, fullMetadata.Title, fullMetadata.SortTitle))
{
result.IsUpdated = true;
}

8
ErsatzTV.Scanner/Core/Plex/PlexOtherVideoLibraryScanner.cs

@ -352,17 +352,15 @@ public class PlexOtherVideoLibraryScanner : @@ -352,17 +352,15 @@ public class PlexOtherVideoLibraryScanner :
result.IsUpdated = true;
}
/*
if (fullMetadata.SortTitle != existingMetadata.SortTitle)
if (fullMetadata.SortTitle != existingMetadata.SortTitle || fullMetadata.Title != existingMetadata.Title)
{
existingMetadata.Title = fullMetadata.Title;
existingMetadata.SortTitle = fullMetadata.SortTitle;
// Not existing on IOtherVideoRepository
if (await _otherVideoRepository.UpdateSortTitle(existingMetadata))
if (await _otherVideoRepository.UpdateTitles(existingMetadata, fullMetadata.Title, fullMetadata.SortTitle))
{
result.IsUpdated = true;
}
}
*/
bool poster = await UpdateArtworkIfNeeded(existingMetadata, fullMetadata, ArtworkKind.Poster);
bool fanArt = await UpdateArtworkIfNeeded(existingMetadata, fullMetadata, ArtworkKind.FanArt);

10
ErsatzTV.Scanner/ErsatzTV.Scanner.csproj

@ -26,16 +26,16 @@ @@ -26,16 +26,16 @@
<PackageReference Include="Humanizer.Core" Version="3.0.10" />
<PackageReference Include="LanguageExt.Core" Version="4.4.9" />
<PackageReference Include="MediatR" Version="[12.5.0]" />
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="10.0.9" />
<PackageReference Include="Microsoft.Extensions.Configuration" Version="10.0.9" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.9" />
<PackageReference Include="Serilog" Version="4.3.1" />
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="10.0.10" />
<PackageReference Include="Microsoft.Extensions.Configuration" Version="10.0.10" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.10" />
<PackageReference Include="Serilog" Version="4.4.0" />
<PackageReference Include="Serilog.AspNetCore" Version="10.0.0" />
<PackageReference Include="Serilog.Extensions.Hosting" Version="10.0.0" />
<PackageReference Include="Serilog.Formatting.Compact" Version="3.0.0" />
<PackageReference Include="Serilog.Sinks.Console" Version="6.1.1" />
<PackageReference Include="Serilog.Sinks.File" Version="7.0.0" />
<PackageReference Include="System.CommandLine" Version="2.0.9" />
<PackageReference Include="System.CommandLine" Version="2.0.10" />
</ItemGroup>
<ItemGroup>

1
ErsatzTV.Tests/ErsatzTV.Tests.csproj

@ -4,6 +4,7 @@ @@ -4,6 +4,7 @@
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<NuGetAuditMode>direct</NuGetAuditMode>
</PropertyGroup>
<ItemGroup>

21
ErsatzTV/ErsatzTV.csproj

@ -13,6 +13,7 @@ @@ -13,6 +13,7 @@
<UserSecretsId>bf31217d-f4ec-4520-8cc3-138059044ede</UserSecretsId>
<AnalysisLevel>latest-Recommended</AnalysisLevel>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<NuGetAuditMode>direct</NuGetAuditMode>
<RequiresAspNetWebAssets>true</RequiresAspNetWebAssets>
<NeutralLanguage>en-US</NeutralLanguage>
</PropertyGroup>
@ -36,30 +37,30 @@ @@ -36,30 +37,30 @@
<PackageReference Include="FluentValidation" Version="12.1.1" />
<PackageReference Include="FluentValidation.AspNetCore" Version="11.3.1" />
<PackageReference Include="Heron.MudCalendar" Version="4.0.0" />
<PackageReference Include="HtmlSanitizer" Version="9.0.892" />
<PackageReference Include="HtmlSanitizer" Version="9.0.967" />
<PackageReference Include="LanguageExt.Core" Version="4.4.9" />
<PackageReference Include="Markdig" Version="1.3.2" />
<PackageReference Include="MediatR.Courier.DependencyInjection" Version="5.0.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.9" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="10.0.9" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="10.0.9" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.9" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.10" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="10.0.10" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="10.0.10" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.10" />
<!-- Override transitive Microsoft.OpenApi 2.0.0 (GHSA-v5pm-xwqc-g5wc) from Microsoft.AspNetCore.OpenApi -->
<PackageReference Include="Microsoft.OpenApi" Version="2.7.5" />
<PackageReference Include="Microsoft.AspNetCore.SpaServices.Extensions" Version="10.0.9" />
<PackageReference Include="Microsoft.AspNetCore.SpaServices.Extensions" Version="10.0.10" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.15">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.Extensions.ApiDescription.Server" Version="10.0.9">
<PackageReference Include="Microsoft.Extensions.ApiDescription.Server" Version="10.0.10">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="MudBlazor" Version="9.6.0" />
<PackageReference Include="MudBlazor" Version="9.7.0" />
<PackageReference Include="NaturalSort.Extension" Version="4.4.1" />
<PackageReference Include="Refit.HttpClientFactory" Version="13.1.0" />
<PackageReference Include="Scalar.AspNetCore" Version="2.16.11" />
<PackageReference Include="Serilog" Version="4.3.1" />
<PackageReference Include="Scalar.AspNetCore" Version="2.16.16" />
<PackageReference Include="Serilog" Version="4.4.0" />
<PackageReference Include="Serilog.AspNetCore" Version="10.0.0" />
<PackageReference Include="Serilog.Settings.Configuration" Version="10.0.1" />
<PackageReference Include="VueCliMiddleware" Version="6.0.0" />

Loading…
Cancel
Save