using ErsatzTV.Core.Domain; using ErsatzTV.Core.Extensions; using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Interfaces.Scheduling; using LanguageExt.UnsafeValueAccess; using Microsoft.Extensions.Logging; using Map = LanguageExt.Map; namespace ErsatzTV.Core.Scheduling; // TODO: these tests fail on days when offset changes // because the change happens during the playout public class PlayoutBuilder : IPlayoutBuilder { private static readonly Random Random = new(); private readonly IArtistRepository _artistRepository; private readonly IConfigElementRepository _configElementRepository; private readonly ILogger _logger; private readonly IMediaCollectionRepository _mediaCollectionRepository; private readonly ITelevisionRepository _televisionRepository; public PlayoutBuilder( IConfigElementRepository configElementRepository, IMediaCollectionRepository mediaCollectionRepository, ITelevisionRepository televisionRepository, IArtistRepository artistRepository, ILogger logger) { _configElementRepository = configElementRepository; _mediaCollectionRepository = mediaCollectionRepository; _televisionRepository = televisionRepository; _artistRepository = artistRepository; _logger = logger; } public async Task Build(Playout playout, PlayoutBuildMode mode) { foreach (PlayoutParameters parameters in await Validate(playout)) { // for testing purposes // if (mode == PlayoutBuildMode.Reset) // { // return await Build(playout, mode, parameters with { Start = parameters.Start.AddDays(-2) }); // } return await Build(playout, mode, parameters); } return playout; } private Task Build(Playout playout, PlayoutBuildMode mode, PlayoutParameters parameters) => mode switch { PlayoutBuildMode.Refresh => RefreshPlayout(playout, parameters), PlayoutBuildMode.Reset => ResetPlayout(playout, parameters), _ => ContinuePlayout(playout, parameters) }; internal async Task Build( Playout playout, PlayoutBuildMode mode, DateTimeOffset start, DateTimeOffset finish) { foreach (PlayoutParameters parameters in await Validate(playout)) { return await Build(playout, mode, parameters with { Start = start, Finish = finish }); } return playout; } private async Task RefreshPlayout(Playout playout, PlayoutParameters parameters) { _logger.LogDebug( "Refreshing playout {PlayoutId} for channel {ChannelNumber} - {ChannelName}", playout.Id, playout.Channel.Number, playout.Channel.Name); playout.Items.Clear(); playout.Anchor = null; // foreach (PlayoutProgramScheduleAnchor anchor in playout.ProgramScheduleAnchors) // { // anchor.Playout = null; // anchor.Collection = null; // anchor.ProgramSchedule = null; // } // _logger.LogDebug("All anchors: {@Anchors}", playout.ProgramScheduleAnchors); // remove null anchor date ("continue" anchors) playout.ProgramScheduleAnchors.RemoveAll(a => a.AnchorDate is null); // _logger.LogDebug("Checkpoint anchors: {@Anchors}", playout.ProgramScheduleAnchors); // remove old checkpoints playout.ProgramScheduleAnchors.RemoveAll( a => a.AnchorDateOffset.IfNone(SystemTime.MaxValueUtc) < parameters.Start.Date); // remove new checkpoints playout.ProgramScheduleAnchors.RemoveAll( a => a.AnchorDateOffset.IfNone(SystemTime.MinValueUtc).Date > parameters.Start.Date); // _logger.LogDebug("Remaining anchors: {@Anchors}", playout.ProgramScheduleAnchors); var allAnchors = playout.ProgramScheduleAnchors.ToList(); var collectionIds = playout.ProgramScheduleAnchors.Map(a => Optional(a.CollectionId)).Somes().ToHashSet(); var multiCollectionIds = playout.ProgramScheduleAnchors.Map(a => Optional(a.MultiCollectionId)).Somes().ToHashSet(); var smartCollectionIds = playout.ProgramScheduleAnchors.Map(a => Optional(a.SmartCollectionId)).Somes().ToHashSet(); var mediaItemIds = playout.ProgramScheduleAnchors.Map(a => Optional(a.MediaItemId)).Somes().ToHashSet(); playout.ProgramScheduleAnchors.Clear(); foreach (int collectionId in collectionIds) { PlayoutProgramScheduleAnchor minAnchor = allAnchors.Filter(a => a.CollectionId == collectionId) .MinBy(a => a.AnchorDateOffset.IfNone(DateTimeOffset.MaxValue).Ticks); playout.ProgramScheduleAnchors.Add(minAnchor); } foreach (int multiCollectionId in multiCollectionIds) { PlayoutProgramScheduleAnchor minAnchor = allAnchors.Filter(a => a.MultiCollectionId == multiCollectionId) .MinBy(a => a.AnchorDateOffset.IfNone(DateTimeOffset.MaxValue).Ticks); playout.ProgramScheduleAnchors.Add(minAnchor); } foreach (int smartCollectionId in smartCollectionIds) { PlayoutProgramScheduleAnchor minAnchor = allAnchors.Filter(a => a.SmartCollectionId == smartCollectionId) .MinBy(a => a.AnchorDateOffset.IfNone(DateTimeOffset.MaxValue).Ticks); playout.ProgramScheduleAnchors.Add(minAnchor); } foreach (int mediaItemId in mediaItemIds) { PlayoutProgramScheduleAnchor minAnchor = allAnchors.Filter(a => a.MediaItemId == mediaItemId) .MinBy(a => a.AnchorDateOffset.IfNone(DateTimeOffset.MaxValue).Ticks); playout.ProgramScheduleAnchors.Add(minAnchor); } // _logger.LogDebug("Oldest anchors for each collection: {@Anchors}", playout.ProgramScheduleAnchors); // convert checkpoints to non-checkpoints // foreach (PlayoutProgramScheduleAnchor anchor in playout.ProgramScheduleAnchors) // { // anchor.AnchorDate = null; // } // _logger.LogDebug("Final anchors: {@Anchors}", playout.ProgramScheduleAnchors); Option maybeAnchorDate = playout.ProgramScheduleAnchors .Map(a => Optional(a.AnchorDate)) .Somes() .HeadOrNone(); foreach (DateTime anchorDate in maybeAnchorDate) { playout.Anchor = new PlayoutAnchor { NextStart = anchorDate }; } return await BuildPlayoutItems( playout, parameters.Start, parameters.Finish, parameters.CollectionMediaItems); } private async Task ResetPlayout(Playout playout, PlayoutParameters parameters) { _logger.LogDebug( "Resetting playout {PlayoutId} for channel {ChannelNumber} - {ChannelName}", playout.Id, playout.Channel.Number, playout.Channel.Name); playout.Items.Clear(); playout.Anchor = null; playout.ProgramScheduleAnchors.Clear(); await BuildPlayoutItems( playout, parameters.Start, parameters.Finish, parameters.CollectionMediaItems); return playout; } private async Task ContinuePlayout(Playout playout, PlayoutParameters parameters) { _logger.LogDebug( "Building playout {PlayoutId} for channel {ChannelNumber} - {ChannelName}", playout.Id, playout.Channel.Number, playout.Channel.Name); // remove old checkpoints playout.ProgramScheduleAnchors.RemoveAll( a => a.AnchorDateOffset.IfNone(SystemTime.MaxValueUtc) < parameters.Start.Date); // _logger.LogDebug("Remaining anchors: {@Anchors}", playout.ProgramScheduleAnchors); await BuildPlayoutItems( playout, parameters.Start, parameters.Finish, parameters.CollectionMediaItems); return playout; } private async Task> Validate(Playout playout) { Map> collectionMediaItems = await GetCollectionMediaItems(playout); if (!collectionMediaItems.Any()) { _logger.LogWarning( "Playout {Playout} schedule {Schedule} has no items", playout.Channel.Name, playout.ProgramSchedule.Name); return None; } Option skipMissingItems = await _configElementRepository.GetValue(ConfigElementKey.PlayoutSkipMissingItems); Option maybeEmptyCollection = await CheckForEmptyCollections( collectionMediaItems, await skipMissingItems.IfNoneAsync(false)); foreach (CollectionKey emptyCollection in maybeEmptyCollection) { Option maybeName = await _mediaCollectionRepository.GetNameFromKey(emptyCollection); if (maybeName.IsSome) { foreach (string name in maybeName) { _logger.LogError( "Unable to rebuild playout; {CollectionType} {CollectionName} has no valid items!", emptyCollection.CollectionType, name); } } else { _logger.LogError( "Unable to rebuild playout; collection {@CollectionKey} has no valid items!", emptyCollection); } return None; } playout.Items ??= new List(); playout.ProgramScheduleAnchors ??= new List(); Option daysToBuild = await _configElementRepository.GetValue(ConfigElementKey.PlayoutDaysToBuild); DateTimeOffset now = DateTimeOffset.Now; return new PlayoutParameters( now, now.AddDays(await daysToBuild.IfNoneAsync(2)), collectionMediaItems); } private async Task BuildPlayoutItems( Playout playout, DateTimeOffset playoutStart, DateTimeOffset playoutFinish, Map> collectionMediaItems) { DateTimeOffset trimBefore = playoutStart.AddHours(-4); DateTimeOffset trimAfter = playoutFinish; DateTimeOffset start = playoutStart; DateTimeOffset finish = playoutStart.Date.AddDays(1); // _logger.LogDebug( // "Trim before: {TrimBefore}, Start: {Start}, Finish: {Finish}, PlayoutFinish: {PlayoutFinish}", // trimBefore, // start, // finish, // playoutFinish); // build each day with "continue" anchors while (finish < playoutFinish) { _logger.LogDebug("Building playout from {Start} to {Finish}", start, finish); playout = await BuildPlayoutItems(playout, start, finish, collectionMediaItems, true); start = playout.Anchor.NextStartOffset; finish = finish.AddDays(1); } if (start < playoutFinish) { // build one final time without continue anchors _logger.LogDebug("Building final playout from {Start} to {Finish}", start, playoutFinish); playout = await BuildPlayoutItems( playout, start, playoutFinish, collectionMediaItems, false); } // remove any items outside the desired range playout.Items.RemoveAll(old => old.FinishOffset < trimBefore || old.StartOffset > trimAfter); return playout; } private async Task BuildPlayoutItems( Playout playout, DateTimeOffset playoutStart, DateTimeOffset playoutFinish, Map> collectionMediaItems, bool saveAnchorDate) { var sortedScheduleItems = playout.ProgramSchedule.Items.OrderBy(i => i.Index).ToList(); CollectionEnumeratorState scheduleItemsEnumeratorState = playout.Anchor?.ScheduleItemsEnumeratorState ?? new CollectionEnumeratorState { Seed = Random.Next(), Index = 0 }; IScheduleItemsEnumerator scheduleItemsEnumerator = playout.ProgramSchedule.ShuffleScheduleItems ? new ShuffledScheduleItemsEnumerator(playout.ProgramSchedule.Items, scheduleItemsEnumeratorState) : new OrderedScheduleItemsEnumerator(playout.ProgramSchedule.Items, scheduleItemsEnumeratorState); var collectionEnumerators = new Dictionary(); foreach ((CollectionKey collectionKey, List mediaItems) in collectionMediaItems) { // use configured playback order for primary collection, shuffle for filler Option maybeScheduleItem = sortedScheduleItems .FirstOrDefault(item => CollectionKey.ForScheduleItem(item) == collectionKey); PlaybackOrder playbackOrder = maybeScheduleItem .Match(item => item.PlaybackOrder, () => PlaybackOrder.Shuffle); IMediaCollectionEnumerator enumerator = await GetMediaCollectionEnumerator(playout, collectionKey, mediaItems, playbackOrder); collectionEnumerators.Add(collectionKey, enumerator); } // find start anchor PlayoutAnchor startAnchor = FindStartAnchor(playout, playoutStart, scheduleItemsEnumerator); // _logger.LogDebug("Start anchor: {@StartAnchor}", startAnchor); // start at the previously-decided time DateTimeOffset currentTime = startAnchor.NextStartOffset.ToLocalTime(); if (currentTime >= playoutFinish) { // nothing to do, no need to add more anchors return playout; } // _logger.LogDebug( // "Starting playout ({PlayoutId}) for channel {ChannelNumber} - {ChannelName} at {StartTime}", // playout.Id, // playout.Channel.Number, // playout.Channel.Name, // currentTime); // removing any items scheduled past the start anchor // this could happen if the app was closed after scheduling items // but before saving the anchor int removed = playout.Items.RemoveAll(pi => pi.StartOffset >= currentTime); if (removed > 0) { _logger.LogWarning("Removed {Count} schedule items beyond current start anchor", removed); } // start with the previously-decided schedule item // start with the previous multiple/duration states var playoutBuilderState = new PlayoutBuilderState( scheduleItemsEnumerator, Optional(startAnchor.MultipleRemaining), startAnchor.DurationFinishOffset, startAnchor.InFlood, startAnchor.InDurationFiller, startAnchor.NextGuideGroup, currentTime); var schedulerOne = new PlayoutModeSchedulerOne(_logger); var schedulerMultiple = new PlayoutModeSchedulerMultiple(collectionMediaItems, _logger); var schedulerDuration = new PlayoutModeSchedulerDuration(_logger); var schedulerFlood = new PlayoutModeSchedulerFlood(_logger); // loop until we're done filling the desired amount of time while (playoutBuilderState.CurrentTime < playoutFinish) { // get the schedule item out of the sorted list ProgramScheduleItem scheduleItem = playoutBuilderState.ScheduleItemsEnumerator.Current; ProgramScheduleItem nextScheduleItem = playoutBuilderState.ScheduleItemsEnumerator.Peek(1); Tuple> result = scheduleItem switch { ProgramScheduleItemMultiple multiple => schedulerMultiple.Schedule( playoutBuilderState, collectionEnumerators, multiple, nextScheduleItem, playoutFinish), ProgramScheduleItemDuration duration => schedulerDuration.Schedule( playoutBuilderState, collectionEnumerators, duration, nextScheduleItem, playoutFinish), ProgramScheduleItemFlood flood => schedulerFlood.Schedule( playoutBuilderState, collectionEnumerators, flood, nextScheduleItem, playoutFinish), ProgramScheduleItemOne one => schedulerOne.Schedule( playoutBuilderState, collectionEnumerators, one, nextScheduleItem, playoutFinish), _ => throw new ArgumentOutOfRangeException(nameof(scheduleItem)) }; (PlayoutBuilderState nextState, List playoutItems) = result; foreach (PlayoutItem playoutItem in playoutItems) { playout.Items.Add(playoutItem); } playoutBuilderState = nextState; } // once more to get playout anchor ProgramScheduleItem anchorScheduleItem = playoutBuilderState.ScheduleItemsEnumerator.Current; if (playout.Items.Any()) { DateTimeOffset maxStartTime = playout.Items.Max(i => i.FinishOffset); if (maxStartTime < playoutBuilderState.CurrentTime) { playoutBuilderState = playoutBuilderState with { CurrentTime = maxStartTime }; } } playout.Anchor = new PlayoutAnchor { ScheduleItemsEnumeratorState = playoutBuilderState.ScheduleItemsEnumerator.State, NextStart = PlayoutModeSchedulerBase .GetStartTimeAfter(playoutBuilderState, anchorScheduleItem) .UtcDateTime, InFlood = playoutBuilderState.InFlood, InDurationFiller = playoutBuilderState.InDurationFiller, NextGuideGroup = playoutBuilderState.NextGuideGroup }; foreach (int multipleRemaining in playoutBuilderState.MultipleRemaining) { playout.Anchor.MultipleRemaining = multipleRemaining; } foreach (DateTimeOffset durationFinish in playoutBuilderState.DurationFinish) { playout.Anchor.DurationFinish = durationFinish.UtcDateTime; } // build program schedule anchors playout.ProgramScheduleAnchors = BuildProgramScheduleAnchors(playout, collectionEnumerators, saveAnchorDate); return playout; } private async Task>> GetCollectionMediaItems(Playout playout) { var collectionKeys = playout.ProgramSchedule.Items .SelectMany(CollectionKeysForItem) .Distinct() .ToList(); IEnumerable>> tuples = await collectionKeys.Map( async collectionKey => Tuple( collectionKey, await MediaItemsForCollection.Collect( _mediaCollectionRepository, _televisionRepository, _artistRepository, collectionKey))).SequenceParallel(); return Map.createRange(tuples); } private async Task> CheckForEmptyCollections( Map> collectionMediaItems, bool skipMissingItems) { foreach ((CollectionKey _, List items) in collectionMediaItems) { var zeroItems = new List(); var missingItems = new List(); foreach (MediaItem item in items) { bool isZero = item switch { Movie m => await m.MediaVersions.Map(v => v.Duration).HeadOrNone() .IfNoneAsync(TimeSpan.Zero) == TimeSpan.Zero, Episode e => await e.MediaVersions.Map(v => v.Duration).HeadOrNone() .IfNoneAsync(TimeSpan.Zero) == TimeSpan.Zero, MusicVideo mv => await mv.MediaVersions.Map(v => v.Duration).HeadOrNone() .IfNoneAsync(TimeSpan.Zero) == TimeSpan.Zero, OtherVideo ov => await ov.MediaVersions.Map(v => v.Duration).HeadOrNone() .IfNoneAsync(TimeSpan.Zero) == TimeSpan.Zero, Song s => await s.MediaVersions.Map(v => v.Duration).HeadOrNone() .IfNoneAsync(TimeSpan.Zero) == TimeSpan.Zero, _ => true }; if (skipMissingItems && item.State is MediaItemState.FileNotFound or MediaItemState.Unavailable) { _logger.LogWarning( "Skipping media item that does not exist on disk {MediaItem} - {MediaItemTitle} - {Path}", item.Id, DisplayTitle(item), item.GetHeadVersion().MediaFiles.Head().Path); missingItems.Add(item); } else if (isZero) { _logger.LogWarning( "Skipping media item with zero duration {MediaItem} - {MediaItemTitle}", item.Id, DisplayTitle(item)); zeroItems.Add(item); } } items.RemoveAll(missingItems.Contains); items.RemoveAll(zeroItems.Contains); } return collectionMediaItems.Find(c => !c.Value.Any()).Map(c => c.Key); } private static PlayoutAnchor FindStartAnchor( Playout playout, DateTimeOffset start, IScheduleItemsEnumerator enumerator) => Optional(playout.Anchor).IfNone( () => { ProgramScheduleItem schedule = enumerator.Current; switch (schedule.StartType) { case StartType.Fixed: return new PlayoutAnchor { ScheduleItemsEnumeratorState = enumerator.State, NextStart = (start - start.TimeOfDay).UtcDateTime + schedule.StartTime.GetValueOrDefault() }; case StartType.Dynamic: default: return new PlayoutAnchor { ScheduleItemsEnumeratorState = enumerator.State, NextStart = (start - start.TimeOfDay).UtcDateTime }; } }); private static List BuildProgramScheduleAnchors( Playout playout, Dictionary collectionEnumerators, bool saveAnchorDate) { var result = new List(); foreach (CollectionKey collectionKey in collectionEnumerators.Keys) { Option maybeExisting = playout.ProgramScheduleAnchors.FirstOrDefault( a => a.CollectionType == collectionKey.CollectionType && a.CollectionId == collectionKey.CollectionId && a.MediaItemId == collectionKey.MediaItemId && a.AnchorDate is null); var maybeEnumeratorState = collectionEnumerators.GroupBy(e => e.Key, e => e.Value.State).ToDictionary( mcs => mcs.Key, mcs => mcs.Head()); PlayoutProgramScheduleAnchor scheduleAnchor = maybeExisting.Match( existing => { existing.EnumeratorState = maybeEnumeratorState[collectionKey]; return existing; }, () => new PlayoutProgramScheduleAnchor { Playout = playout, PlayoutId = playout.Id, ProgramSchedule = playout.ProgramSchedule, ProgramScheduleId = playout.ProgramScheduleId, CollectionType = collectionKey.CollectionType, CollectionId = collectionKey.CollectionId, MultiCollectionId = collectionKey.MultiCollectionId, SmartCollectionId = collectionKey.SmartCollectionId, MediaItemId = collectionKey.MediaItemId, EnumeratorState = maybeEnumeratorState[collectionKey] }); if (saveAnchorDate) { scheduleAnchor.AnchorDate = playout.Anchor?.NextStart; } result.Add(scheduleAnchor); } foreach (PlayoutProgramScheduleAnchor continueAnchor in playout.ProgramScheduleAnchors.Where( a => a.AnchorDate is not null)) { result.Add(continueAnchor); } return result; } private async Task GetMediaCollectionEnumerator( Playout playout, CollectionKey collectionKey, List mediaItems, PlaybackOrder playbackOrder) { Option maybeAnchor = playout.ProgramScheduleAnchors .OrderByDescending(a => a.AnchorDate is null) .FirstOrDefault( a => a.ProgramScheduleId == playout.ProgramScheduleId && a.CollectionType == collectionKey.CollectionType && a.CollectionId == collectionKey.CollectionId && a.MultiCollectionId == collectionKey.MultiCollectionId && a.SmartCollectionId == collectionKey.SmartCollectionId && a.MediaItemId == collectionKey.MediaItemId); // foreach (PlayoutProgramScheduleAnchor anchor in maybeAnchor) // { // _logger.LogDebug("Selecting anchor {@Anchor}", anchor); // } CollectionEnumeratorState state = maybeAnchor.Match( anchor => anchor.EnumeratorState ?? (anchor.EnumeratorState = new CollectionEnumeratorState { Seed = Random.Next(), Index = 0 }), () => new CollectionEnumeratorState { Seed = Random.Next(), Index = 0 }); if (await _mediaCollectionRepository.IsCustomPlaybackOrder(collectionKey.CollectionId ?? 0)) { Option collectionWithItems = await _mediaCollectionRepository.GetCollectionWithCollectionItemsUntracked( collectionKey.CollectionId ?? 0); if (collectionKey.CollectionType == ProgramScheduleItemCollectionType.Collection && collectionWithItems.IsSome) { return new CustomOrderCollectionEnumerator( collectionWithItems.ValueUnsafe(), mediaItems, state); } } switch (playbackOrder) { case PlaybackOrder.Chronological: return new ChronologicalMediaCollectionEnumerator(mediaItems, state); case PlaybackOrder.Random: return new RandomizedMediaCollectionEnumerator(mediaItems, state); case PlaybackOrder.Shuffle: return new ShuffledMediaCollectionEnumerator( await GetGroupedMediaItemsForShuffle(playout, mediaItems, collectionKey), state); case PlaybackOrder.ShuffleInOrder: return new ShuffleInOrderCollectionEnumerator( await GetCollectionItemsForShuffleInOrder(collectionKey), state); default: // TODO: handle this error case differently? return new RandomizedMediaCollectionEnumerator(mediaItems, state); } } private async Task> GetGroupedMediaItemsForShuffle( Playout playout, List mediaItems, CollectionKey collectionKey) { if (collectionKey.MultiCollectionId != null) { List collections = await _mediaCollectionRepository .GetMultiCollectionCollections(collectionKey.MultiCollectionId.Value); return MultiCollectionGrouper.GroupMediaItems(collections); } return playout.ProgramSchedule.KeepMultiPartEpisodesTogether ? MultiPartEpisodeGrouper.GroupMediaItems( mediaItems, playout.ProgramSchedule.TreatCollectionsAsShows) : mediaItems.Map(mi => new GroupedMediaItem(mi, null)).ToList(); } private async Task> GetCollectionItemsForShuffleInOrder(CollectionKey collectionKey) { List result; if (collectionKey.MultiCollectionId != null) { result = await _mediaCollectionRepository.GetMultiCollectionCollections( collectionKey.MultiCollectionId.Value); } else { result = await _mediaCollectionRepository.GetFakeMultiCollectionCollections( collectionKey.CollectionId, collectionKey.SmartCollectionId); } return result; } internal static string DisplayTitle(MediaItem mediaItem) { switch (mediaItem) { case Episode e: string showTitle = e.Season.Show.ShowMetadata.HeadOrNone() .Map(sm => $"{sm.Title} - ").IfNone(string.Empty); return e.EpisodeMetadata.HeadOrNone() .Map(em => $"{showTitle}s{e.Season.SeasonNumber:00}e{em.EpisodeNumber:00} - {em.Title}") .IfNone("[unknown episode]"); case Movie m: return m.MovieMetadata.HeadOrNone().Match(mm => mm.Title ?? string.Empty, () => "[unknown movie]"); case MusicVideo mv: string artistName = mv.Artist.ArtistMetadata.HeadOrNone() .Map(am => $"{am.Title} - ").IfNone(string.Empty); return mv.MusicVideoMetadata.HeadOrNone() .Map(mvm => $"{artistName}{mvm.Title}") .IfNone("[unknown music video]"); case OtherVideo ov: return ov.OtherVideoMetadata.HeadOrNone().Match( ovm => ovm.Title ?? string.Empty, () => "[unknown video]"); case Song s: return s.SongMetadata.HeadOrNone().Match( sm => sm.Title ?? string.Empty, () => "[unknown song]"); default: return string.Empty; } } private static List CollectionKeysForItem(ProgramScheduleItem item) { var result = new List { CollectionKey.ForScheduleItem(item) }; if (item.PreRollFiller != null) { result.Add(CollectionKey.ForFillerPreset(item.PreRollFiller)); } if (item.MidRollFiller != null) { result.Add(CollectionKey.ForFillerPreset(item.MidRollFiller)); } if (item.PostRollFiller != null) { result.Add(CollectionKey.ForFillerPreset(item.PostRollFiller)); } if (item.TailFiller != null) { result.Add(CollectionKey.ForFillerPreset(item.TailFiller)); } if (item.FallbackFiller != null) { result.Add(CollectionKey.ForFillerPreset(item.FallbackFiller)); } return result; } }