CI's Formatting job failed: 19 touched files carried a BOM, which .editorconfig forbids (charset=utf-8). Pure encoding change — one byte per file, no semantic diff (verified: every hunk is `-namespace` -> `+namespace`). Self-inflicted. The patches that edited these legacy files wrote them back as utf-8-sig to "preserve the existing style", but the #311 fix-as-you-touch gate requires a file to be normalized when you touch it — that is the whole point of scoping the gate to changed files instead of reformatting the ~2500 legacy BOM files at once. dotnet format leaves the EF-generated Designer/snapshot files alone as generated code, and its verify skips them the same way, so they stay as ef emitted them. Two corrections to what I believed going in: - `dotnet format --include` does NOT no-op here. It reported `error CHARSET` for each file and exit 2, reproducing CI exactly, and fixed them in place. The note claiming otherwise is wrong for this invocation. - My first BOM check reported all files clean. The od pattern was wrong; reading the first three bytes directly found 19. A detector that can only say "ok" is worse than no detector. Core.Tests 565, ErsatzTV.Tests 1673, Architecture.Tests 5 — all passed. API artifacts still in sync. Refs #70 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
206 lines
8.2 KiB
C#
206 lines
8.2 KiB
C#
using ErsatzTV.Core.Domain;
|
|
using ErsatzTV.Core.Interfaces.Repositories;
|
|
using ErsatzTV.Core.Interfaces.Scheduling;
|
|
using ErsatzTV.Core.Scheduling.BlockScheduling;
|
|
using ErsatzTV.Core.Scheduling.Engine;
|
|
using ErsatzTV.Core.Scheduling.YamlScheduling.Models;
|
|
using Microsoft.Extensions.Logging;
|
|
|
|
namespace ErsatzTV.Core.Scheduling.YamlScheduling;
|
|
|
|
public class EnumeratorCache(IMediaCollectionRepository mediaCollectionRepository, ILogger logger)
|
|
{
|
|
private readonly Dictionary<string, IMediaCollectionEnumerator> _enumerators = new();
|
|
private readonly Dictionary<string, List<MediaItem>> _mediaItems = new();
|
|
private readonly Dictionary<PlaylistKey, List<MediaItem>> _playlistMediaItems = new();
|
|
|
|
public System.Collections.Generic.HashSet<string> MissingContentKeys { get; } = [];
|
|
|
|
public List<MediaItem> MediaItemsForContent(string contentKey) =>
|
|
_mediaItems.TryGetValue(contentKey, out List<MediaItem> items) ? items : [];
|
|
|
|
public List<MediaItem> PlaylistMediaItemsForContent(string contentKey, CollectionKey collectionKey) =>
|
|
_playlistMediaItems.TryGetValue(new PlaylistKey(contentKey, collectionKey), out List<MediaItem> items)
|
|
? items
|
|
: [];
|
|
|
|
public async Task<Option<IMediaCollectionEnumerator>> GetCachedEnumeratorForContent(
|
|
YamlPlayoutContext context,
|
|
string contentKey,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(contentKey))
|
|
{
|
|
return Option<IMediaCollectionEnumerator>.None;
|
|
}
|
|
|
|
if (!_enumerators.TryGetValue(contentKey, out IMediaCollectionEnumerator enumerator))
|
|
{
|
|
Option<IMediaCollectionEnumerator> maybeEnumerator =
|
|
await GetEnumeratorForContent(context, contentKey, cancellationToken);
|
|
|
|
if (maybeEnumerator.IsNone)
|
|
{
|
|
return Option<IMediaCollectionEnumerator>.None;
|
|
}
|
|
|
|
foreach (IMediaCollectionEnumerator e in maybeEnumerator)
|
|
{
|
|
enumerator = e;
|
|
_enumerators.Add(contentKey, enumerator);
|
|
}
|
|
}
|
|
|
|
return Some(enumerator);
|
|
}
|
|
|
|
private async Task<Option<IMediaCollectionEnumerator>> GetEnumeratorForContent(
|
|
YamlPlayoutContext context,
|
|
string contentKey,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
int index = context.Definition.Content.FindIndex(c => c.Key == contentKey);
|
|
if (index < 0)
|
|
{
|
|
return Option<IMediaCollectionEnumerator>.None;
|
|
}
|
|
|
|
List<MediaItem> items = [];
|
|
|
|
YamlPlayoutContentItem content = context.Definition.Content[index];
|
|
switch (content)
|
|
{
|
|
case YamlPlayoutContentSearchItem search:
|
|
items = await mediaCollectionRepository.GetSmartCollectionItems(search.Query, string.Empty, cancellationToken);
|
|
break;
|
|
case YamlPlayoutContentShowItem show:
|
|
items = await mediaCollectionRepository.GetShowItemsByShowGuids(
|
|
show.Guids.Map(g => $"{g.Source}://{g.Value}").ToList());
|
|
break;
|
|
case YamlPlayoutContentCollectionItem collection:
|
|
items = await mediaCollectionRepository.GetCollectionItemsByName(
|
|
collection.Collection,
|
|
cancellationToken);
|
|
break;
|
|
case YamlPlayoutContentSmartCollectionItem smartCollection:
|
|
items = await mediaCollectionRepository.GetSmartCollectionItemsByName(
|
|
smartCollection.SmartCollection,
|
|
cancellationToken);
|
|
break;
|
|
case YamlPlayoutContentMultiCollectionItem multiCollection:
|
|
items = await mediaCollectionRepository.GetMultiCollectionItemsByName(
|
|
multiCollection.MultiCollection,
|
|
cancellationToken);
|
|
break;
|
|
|
|
// playlist is handled later
|
|
}
|
|
|
|
_mediaItems[content.Key] = items;
|
|
|
|
var state = new CollectionEnumeratorState { Seed = context.Playout.Seed + index, Index = 0 };
|
|
|
|
// marathon is a special case that needs to be handled on its own
|
|
if (content is YamlPlayoutContentMarathonItem marathon)
|
|
{
|
|
var helper = new MarathonHelper(mediaCollectionRepository);
|
|
var guids = new Dictionary<string, List<string>>();
|
|
foreach (var guid in marathon.Guids)
|
|
{
|
|
if (!guids.TryGetValue(guid.Source, out List<string> value))
|
|
{
|
|
value = [];
|
|
guids.Add(guid.Source, value);
|
|
}
|
|
|
|
value.Add(guid.Value);
|
|
}
|
|
|
|
if (!Enum.TryParse(marathon.ItemOrder, true, out PlaybackOrder itemPlaybackOrder))
|
|
{
|
|
itemPlaybackOrder = PlaybackOrder.Shuffle;
|
|
}
|
|
|
|
Option<PlaylistContentResult> maybeResult = await helper.GetEnumerator(
|
|
guids,
|
|
marathon.Searches,
|
|
marathon.GroupBy,
|
|
marathon.ShuffleGroups,
|
|
itemPlaybackOrder,
|
|
marathon.PlayAllItems,
|
|
state,
|
|
cancellationToken);
|
|
|
|
foreach (PlaylistContentResult result in maybeResult)
|
|
{
|
|
foreach ((CollectionKey collectionKey, List<MediaItem> mediaItems) in result.Content)
|
|
{
|
|
_playlistMediaItems.Add(new PlaylistKey(contentKey, collectionKey), mediaItems);
|
|
}
|
|
|
|
return result.PlaylistEnumerator;
|
|
}
|
|
}
|
|
|
|
// playlist is a special case that needs to be handled on its own
|
|
if (content is YamlPlayoutContentPlaylistItem playlist)
|
|
{
|
|
if (!string.IsNullOrWhiteSpace(playlist.Order) && !string.Equals(
|
|
playlist.Order,
|
|
"none",
|
|
StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
logger.LogWarning(
|
|
"Ignoring playback order {Order} for playlist {Playlist}",
|
|
playlist.Order,
|
|
playlist.Playlist);
|
|
}
|
|
|
|
Dictionary<PlaylistItem, List<MediaItem>> itemMap =
|
|
await mediaCollectionRepository.GetPlaylistItemMap(
|
|
playlist.PlaylistGroup,
|
|
playlist.Playlist,
|
|
cancellationToken);
|
|
|
|
foreach ((PlaylistItem playlistItem, List<MediaItem> mediaItems) in itemMap)
|
|
{
|
|
_playlistMediaItems.Add(
|
|
new PlaylistKey(contentKey, CollectionKey.ForPlaylistItem(playlistItem)),
|
|
mediaItems);
|
|
}
|
|
|
|
return await PlaylistEnumerator.Create(
|
|
mediaCollectionRepository,
|
|
itemMap,
|
|
state,
|
|
shufflePlaylistItems: false,
|
|
batchSize: Option<int>.None,
|
|
cancellationToken);
|
|
}
|
|
|
|
var parsedOrder = Enum.Parse<PlaybackOrder>(content.Order, true);
|
|
switch (parsedOrder)
|
|
{
|
|
case PlaybackOrder.Chronological:
|
|
return new ChronologicalMediaCollectionEnumerator(items, state);
|
|
case PlaybackOrder.Shuffle:
|
|
bool keepMultiPartEpisodesTogether = content.MultiPart;
|
|
List<GroupedMediaItem> groupedMediaItems = keepMultiPartEpisodesTogether
|
|
? MultiPartEpisodeGrouper.GroupMediaItems(items, false)
|
|
: items.Map(mi => new GroupedMediaItem(mi, null)).ToList();
|
|
return new BlockPlayoutShuffledMediaCollectionEnumerator(groupedMediaItems, state);
|
|
}
|
|
|
|
// this path schedules nothing for the content, which is indistinguishable from "no items" downstream.
|
|
// Orders are addressed by name here, so any order the enum knows parses fine and then lands here --
|
|
// say so, rather than leaving an empty schedule to be explained (#70).
|
|
logger.LogWarning(
|
|
"Playback order {PlaybackOrder} is not supported by sequential (YAML) scheduling; no content will be scheduled for this entry",
|
|
parsedOrder);
|
|
|
|
return Option<IMediaCollectionEnumerator>.None;
|
|
}
|
|
|
|
private record PlaylistKey(string ContentKey, CollectionKey CollectionKey);
|
|
}
|