diff --git a/CHANGELOG.md b/CHANGELOG.md
index 366b1e308..07232925e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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
diff --git a/ErsatzTV.Application/ErsatzTV.Application.csproj b/ErsatzTV.Application/ErsatzTV.Application.csproj
index 0ac00ea47..133ef6984 100644
--- a/ErsatzTV.Application/ErsatzTV.Application.csproj
+++ b/ErsatzTV.Application/ErsatzTV.Application.csproj
@@ -14,8 +14,8 @@
-
-
+
+
diff --git a/ErsatzTV.Core.Tests/ErsatzTV.Core.Tests.csproj b/ErsatzTV.Core.Tests/ErsatzTV.Core.Tests.csproj
index 9a882a6a1..c40941431 100644
--- a/ErsatzTV.Core.Tests/ErsatzTV.Core.Tests.csproj
+++ b/ErsatzTV.Core.Tests/ErsatzTV.Core.Tests.csproj
@@ -9,20 +9,20 @@
-
-
-
-
-
-
-
+
+
+
+
+
+
+
-
+
-
+
diff --git a/ErsatzTV.Core.Tests/Scheduling/PlaylistEnumeratorTests.cs b/ErsatzTV.Core.Tests/Scheduling/PlaylistEnumeratorTests.cs
index 1d174815b..c12593b1b 100644
--- a/ErsatzTV.Core.Tests/Scheduling/PlaylistEnumeratorTests.cs
+++ b/ErsatzTV.Core.Tests/Scheduling/PlaylistEnumeratorTests.cs
@@ -62,6 +62,7 @@ public class PlaylistEnumeratorTests
new CollectionEnumeratorState(),
shufflePlaylistItems: false,
batchSize: Option.None,
+ randomStartPoint: false,
CancellationToken.None);
var items = new List();
@@ -130,6 +131,7 @@ public class PlaylistEnumeratorTests
new CollectionEnumeratorState(),
shufflePlaylistItems: false,
batchSize: Option.None,
+ randomStartPoint: false,
CancellationToken.None);
var items = new List();
@@ -200,6 +202,7 @@ public class PlaylistEnumeratorTests
state,
shufflePlaylistItems: true,
batchSize: Option.None,
+ randomStartPoint: false,
CancellationToken.None);
var items = new List();
@@ -270,6 +273,7 @@ public class PlaylistEnumeratorTests
state,
shufflePlaylistItems: true,
batchSize: Option.None,
+ randomStartPoint: false,
CancellationToken.None);
var items = new List();
@@ -352,11 +356,107 @@ public class PlaylistEnumeratorTests
state,
shufflePlaylistItems: true,
batchSize: Option.None,
+ randomStartPoint: false,
CancellationToken.None);
enumerator.CountForFiller.ShouldBe(7);
}
+ [Test]
+ public async Task RandomStartPoint_Should_Continue_After_Rebuild()
+ {
+ IMediaCollectionRepository repo = Substitute.For();
+
+ Dictionary> 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.None,
+ randomStartPoint: true,
+ CancellationToken.None);
+
+ // capture a reference sequence and the state we would persist halfway through
+ var reference = new List();
+ CollectionEnumeratorState persistedState = null;
+ for (var i = 0; i < 12; i++)
+ {
+ reference.AddRange(day1.Current.Map(mi => mi.Id));
+ day1.MoveNext(Option.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.None,
+ randomStartPoint: true,
+ CancellationToken.None);
+
+ var continued = new List();
+ for (var i = 0; i < 6; i++)
+ {
+ continued.AddRange(day2.Current.Map(mi => mi.Id));
+ day2.MoveNext(Option.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,
diff --git a/ErsatzTV.Core/Domain/CollectionEnumeratorState.cs b/ErsatzTV.Core/Domain/CollectionEnumeratorState.cs
index 3cf6a3104..4b4521dee 100644
--- a/ErsatzTV.Core/Domain/CollectionEnumeratorState.cs
+++ b/ErsatzTV.Core/Domain/CollectionEnumeratorState.cs
@@ -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 };
}
diff --git a/ErsatzTV.Core/ErsatzTV.Core.csproj b/ErsatzTV.Core/ErsatzTV.Core.csproj
index 02be50aed..d753c77f6 100644
--- a/ErsatzTV.Core/ErsatzTV.Core.csproj
+++ b/ErsatzTV.Core/ErsatzTV.Core.csproj
@@ -16,19 +16,19 @@
-
-
-
-
+
+
+
+
-
+
-
-
-
-
+
+
+
+
diff --git a/ErsatzTV.Core/Interfaces/Repositories/IMovieRepository.cs b/ErsatzTV.Core/Interfaces/Repositories/IMovieRepository.cs
index caa8b041a..52558f816 100644
--- a/ErsatzTV.Core/Interfaces/Repositories/IMovieRepository.cs
+++ b/ErsatzTV.Core/Interfaces/Repositories/IMovieRepository.cs
@@ -20,7 +20,7 @@ public interface IMovieRepository
Task AddTag(MovieMetadata metadata, Tag tag);
Task AddStudio(MovieMetadata metadata, Studio studio);
Task AddActor(MovieMetadata metadata, Actor actor);
- Task UpdateSortTitle(MovieMetadata movieMetadata);
+ Task UpdateTitles(MovieMetadata movieMetadata, string title, string sortTitle);
Task AddDirector(MovieMetadata metadata, Director director);
Task AddWriter(MovieMetadata metadata, Writer writer);
}
diff --git a/ErsatzTV.Core/Interfaces/Repositories/IOtherVideoRepository.cs b/ErsatzTV.Core/Interfaces/Repositories/IOtherVideoRepository.cs
index 819477dfc..91ea35fa9 100644
--- a/ErsatzTV.Core/Interfaces/Repositories/IOtherVideoRepository.cs
+++ b/ErsatzTV.Core/Interfaces/Repositories/IOtherVideoRepository.cs
@@ -19,4 +19,5 @@ public interface IOtherVideoRepository
Task AddActor(OtherVideoMetadata metadata, Actor actor);
Task AddDirector(OtherVideoMetadata metadata, Director director);
Task AddWriter(OtherVideoMetadata metadata, Writer writer);
+ Task UpdateTitles(OtherVideoMetadata metadata, string title, string sortTitle);
}
diff --git a/ErsatzTV.Core/Scheduling/BlockScheduling/BlockPlayoutEnumerator.cs b/ErsatzTV.Core/Scheduling/BlockScheduling/BlockPlayoutEnumerator.cs
index 2cb096bad..4c6586f42 100644
--- a/ErsatzTV.Core/Scheduling/BlockScheduling/BlockPlayoutEnumerator.cs
+++ b/ErsatzTV.Core/Scheduling/BlockScheduling/BlockPlayoutEnumerator.cs
@@ -168,6 +168,7 @@ public static class BlockPlayoutEnumerator
state,
shufflePlaylistItems: false,
batchSize: Option.None,
+ randomStartPoint: false,
cancellationToken);
DateTime historyTime = currentTime.UtcDateTime;
diff --git a/ErsatzTV.Core/Scheduling/BlockScheduling/BlockPlayoutShuffledMediaCollectionEnumerator.cs b/ErsatzTV.Core/Scheduling/BlockScheduling/BlockPlayoutShuffledMediaCollectionEnumerator.cs
index 8452b82ea..eee823e87 100644
--- a/ErsatzTV.Core/Scheduling/BlockScheduling/BlockPlayoutShuffledMediaCollectionEnumerator.cs
+++ b/ErsatzTV.Core/Scheduling/BlockScheduling/BlockPlayoutShuffledMediaCollectionEnumerator.cs
@@ -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
{
_shuffled = Shuffle(_mediaItems);
}
+
+ State.Started = true;
}
public Option MinimumDuration => _lazyMinimumDuration.Value;
diff --git a/ErsatzTV.Core/Scheduling/ChronologicalMediaCollectionEnumerator.cs b/ErsatzTV.Core/Scheduling/ChronologicalMediaCollectionEnumerator.cs
index 108699c98..13dd95fe4 100644
--- a/ErsatzTV.Core/Scheduling/ChronologicalMediaCollectionEnumerator.cs
+++ b/ErsatzTV.Core/Scheduling/ChronologicalMediaCollectionEnumerator.cs
@@ -22,7 +22,7 @@ public sealed class ChronologicalMediaCollectionEnumerator : IMediaCollectionEnu
_lazyMediaItemGroupSize = new Lazy>(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
}
}
- 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
{
State.Index = (State.Index + 1) % _sortedMediaItems.Count;
}
+
+ State.Started = true;
}
public Option MinimumDuration => _lazyMinimumDuration.Value;
diff --git a/ErsatzTV.Core/Scheduling/CustomOrderCollectionEnumerator.cs b/ErsatzTV.Core/Scheduling/CustomOrderCollectionEnumerator.cs
index 09b6d4f58..f94d0f8a6 100644
--- a/ErsatzTV.Core/Scheduling/CustomOrderCollectionEnumerator.cs
+++ b/ErsatzTV.Core/Scheduling/CustomOrderCollectionEnumerator.cs
@@ -25,16 +25,19 @@ public class CustomOrderCollectionEnumerator : IMediaCollectionEnumerator
_lazyMinimumDuration = new Lazy